diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..c92d1a789
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,33 @@
+{
+ "name": "OpenFeature .NET SDK",
+ "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm",
+ "features": {
+ "ghcr.io/devcontainers/features/dotnet:latest": {
+ "version": "9.0",
+ "additionalVersions": "8.0"
+ },
+ "ghcr.io/devcontainers/features/github-cli:latest": {},
+ "ghcr.io/devcontainers/features/docker-in-docker": {}
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "EditorConfig.EditorConfig",
+ "GitHub.copilot",
+ "GitHub.copilot-chat",
+ "GitHub.vscode-github-actions",
+ "GitHub.vscode-pull-request-github",
+ "ms-dotnettools.csharp",
+ "esbenp.prettier-vscode",
+ "redhat.vscode-yaml",
+ "cucumberopen.cucumber-official",
+ "ms-dotnettools.csdevkit"
+ ]
+ }
+ },
+ "remoteUser": "vscode",
+ "hostRequirements": {
+ "memory": "8gb"
+ },
+ "postCreateCommand": "git submodule update --init --recursive"
+}
diff --git a/.github/actions/sbom-generator/action.yml b/.github/actions/sbom-generator/action.yml
index 0af8c2c47..9d27d486b 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@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
with:
subject-path: src/**/${{ inputs.project-name }}.*.nupkg
sbom-path: ./artifacts/sboms/${{ inputs.project-name }}.bom.json
diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml
new file mode 100644
index 000000000..76e16c246
--- /dev/null
+++ b/.github/workflows/aot-compatibility.yml
@@ -0,0 +1,95 @@
+name: AOT Compatibility
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ merge_group:
+ workflow_dispatch:
+
+jobs:
+ aot-compatibility:
+ name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }})
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Linux x64
+ - os: ubuntu-latest
+ arch: x64
+ runtime: linux-x64
+ # Linux ARM64
+ - os: ubuntu-24.04-arm
+ arch: arm64
+ runtime: linux-arm64
+ # Windows x64
+ - os: windows-latest
+ arch: x64
+ runtime: win-x64
+ # Windows ARM64
+ - os: windows-11-arm
+ arch: arm64
+ runtime: win-arm64
+ # macOS x64
+ - os: macos-13
+ arch: x64
+ runtime: osx-x64
+ # macOS ARM64 (Apple Silicon)
+ - os: macos-latest
+ arch: arm64
+ runtime: osx-arm64
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
+ with:
+ global-json-file: global.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-${{ matrix.arch }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ matrix.arch }}-nuget-
+ ${{ runner.os }}-nuget-
+
+ - name: Restore dependencies
+ shell: pwsh
+ run: dotnet restore
+
+ - name: Build solution
+ shell: pwsh
+ run: dotnet build -c Release --no-restore
+
+ - name: Test AOT compatibility project build
+ shell: pwsh
+ run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore
+
+ - name: Publish AOT compatibility test (cross-platform)
+ shell: pwsh
+ run: |
+ dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj `
+ -r ${{ matrix.runtime }} `
+ -o ./aot-output
+
+ - name: Run AOT compatibility test
+ shell: pwsh
+ run: |
+ if ("${{ runner.os }}" -eq "Windows") {
+ ./aot-output/OpenFeature.AotCompatibility.exe
+ } else {
+ chmod +x ./aot-output/OpenFeature.AotCompatibility
+ ./aot-output/OpenFeature.AotCompatibility
+ }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb1c72273..f62e3cd44 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,18 +23,23 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
submodules: recursive
- name: Setup .NET SDK
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
- env:
- NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
with:
global-json-file: global.json
- source-url: https://nuget.pkg.github.com/open-feature/index.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore
@@ -45,6 +50,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
@@ -58,18 +67,23 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
submodules: recursive
- name: Setup .NET SDK
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
- env:
- NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
with:
global-json-file: global.json
- source-url: https://nuget.pkg.github.com/open-feature/index.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }}
+ 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..e6c2518c0 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -22,22 +22,27 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup .NET SDK
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
- env:
- NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
with:
global-json-file: global.json
- source-url: https://nuget.pkg.github.com/open-feature/index.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
- name: Run Test
run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
- - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
+ - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with:
name: Code Coverage for ${{ matrix.os }}
fail_ci_if_error: true
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 2f4645ece..74378bfc9 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -38,11 +38,11 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3
+ uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4
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@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4
# โน๏ธ 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@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4
diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml
index e35e37756..f7857ea64 100644
--- a/.github/workflows/dotnet-format.yml
+++ b/.github/workflows/dotnet-format.yml
@@ -15,12 +15,14 @@ jobs:
steps:
- name: Check out code
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup .NET SDK
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
with:
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/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index ae0ca8391..da2edad27 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -17,17 +17,22 @@ jobs:
contents: read
pull-requests: write
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup .NET SDK
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
- env:
- NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
with:
global-json-file: global.json
- source-url: https://nuget.pkg.github.com/open-feature/index.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
- name: Initialize Tests
run: |
diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml
index f23079276..2aad982e7 100644
--- a/.github/workflows/lint-pr.yml
+++ b/.github/workflows/lint-pr.yml
@@ -12,9 +12,31 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
permissions:
- contents: read
pull-requests: write
steps:
- - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
+ id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
+ # When the previous steps fails, the workflow would stop. By adding this
+ # condition you can continue the execution with the populated error message.
+ if: always() && (steps.lint_pr_title.outputs.error_message != null)
+ with:
+ header: pr-title-lint-error
+ message: |
+ Hey there and thank you for opening this pull request! ๐๐ผ
+
+ We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
+ Details:
+
+ ```
+ ${{ steps.lint_pr_title.outputs.error_message }}
+ ```
+ # Delete a previous comment when the issue has been resolved
+ - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
+ uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
+ with:
+ header: pr-title-lint-error
+ delete: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 47898d387..caf7f9e6f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 #v4
+ - uses: googleapis/release-please-action@c2a5a2bd6a758a0937f1ddb1e8950609867ed15c # v4
id: release
with:
token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}}
@@ -32,24 +32,29 @@ jobs:
runs-on: ubuntu-latest
needs: release-please
permissions:
- id-token: write
+ id-token: write # enable GitHub OIDC token issuance for this job (NuGet login)
contents: write # for SBOM release
attestations: write # for actions/attest-sbom to create attestation
packages: read # for internal nuget reading
if: ${{ fromJSON(needs.release-please.outputs.release_created || false) }}
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup .NET SDK
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
- env:
- NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5
with:
global-json-file: global.json
- source-url: https://nuget.pkg.github.com/open-feature/index.json
+
+ - name: Cache NuGet packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
- name: Install dependencies
run: dotnet restore
@@ -57,11 +62,18 @@ jobs:
- name: Pack
run: dotnet pack -c Release --no-restore
+ # Get a short-lived NuGet API key
+ - name: NuGet login (OIDC โ temp API key)
+ uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # v1
+ id: login
+ with:
+ user: ${{secrets.NUGET_USER}}
+
- name: Publish to Nuget
- run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json
+ run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --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@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-path: "src/**/*.nupkg"
@@ -88,3 +100,11 @@ jobs:
github-token: ${{secrets.GITHUB_TOKEN}}
project-name: OpenFeature.DependencyInjection
release-tag: ${{ needs.release-please.outputs.release_tag_name }}
+
+ # Process OpenFeature.Providers.MultiProvider project
+ - name: Generate and Attest SBOM for OpenFeature.Providers.MultiProvider
+ uses: ./.github/actions/sbom-generator
+ with:
+ github-token: ${{secrets.GITHUB_TOKEN}}
+ project-name: OpenFeature.Providers.MultiProvider
+ release-tag: ${{ needs.release-please.outputs.release_tag_name }}
diff --git a/.gitignore b/.gitignore
index 055ffe50f..c77e4f530 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,6 @@
*.user
*.userosscache
*.sln.docstates
-.devcontainer/
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 69e82f12f..a3906fc08 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.6.0"
+ ".": "2.9.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e06ef65bb..bdbe5b608 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,112 @@
# Changelog
+## [2.9.0](https://github.com/open-feature/dotnet-sdk/compare/v2.8.1...v2.9.0) (2025-10-16)
+
+
+### ๐ Bug Fixes
+
+* update provider status to Fatal during disposal ([#580](https://github.com/open-feature/dotnet-sdk/issues/580)) ([76bd94b](https://github.com/open-feature/dotnet-sdk/commit/76bd94b03ea19ad3c432a52dd644317e362b99ec))
+
+
+### โจ New Features
+
+* Add events to the multi provider ([#568](https://github.com/open-feature/dotnet-sdk/issues/568)) ([9d8ab03](https://github.com/open-feature/dotnet-sdk/commit/9d8ab037df1749d098f5e1e210f71cf9d1e7adff))
+* Add multi-provider support ([#488](https://github.com/open-feature/dotnet-sdk/issues/488)) ([7237053](https://github.com/open-feature/dotnet-sdk/commit/7237053561d9c36194197169734522f0b978f6e5))
+* Deprecate AddHostedFeatureLifecycle method ([#531](https://github.com/open-feature/dotnet-sdk/issues/531)) ([fdf2297](https://github.com/open-feature/dotnet-sdk/commit/fdf229737118639d323e74cceac490d44c4c24dd))
+* Implement hooks in multi provider ([#594](https://github.com/open-feature/dotnet-sdk/issues/594)) ([95ae7f0](https://github.com/open-feature/dotnet-sdk/commit/95ae7f03249e351c20ccd6152d88400a7e1ef764))
+* Support retrieving numeric metadata as either integers or decimals ([#490](https://github.com/open-feature/dotnet-sdk/issues/490)) ([12de5f1](https://github.com/open-feature/dotnet-sdk/commit/12de5f10421bac749fdd45c748e7b970f3f69a39))
+
+
+### ๐ Performance
+
+* Add NativeAOT Support ([#554](https://github.com/open-feature/dotnet-sdk/issues/554)) ([acd0486](https://github.com/open-feature/dotnet-sdk/commit/acd0486563f7b67a782ee169315922fb5d0f343e))
+
+## [2.8.1](https://github.com/open-feature/dotnet-sdk/compare/v2.8.0...v2.8.1) (2025-07-31)
+
+
+### ๐ Bug Fixes
+
+* expose ValueJsonConverter for generator support and add JsonSourceGenerator test cases ([#537](https://github.com/open-feature/dotnet-sdk/issues/537)) ([e03aeba](https://github.com/open-feature/dotnet-sdk/commit/e03aeba0f515f668afaba0a3c6f0ea01b44d6ee4))
+
+## [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)
+
+
+### ๐ 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/CONTRIBUTING.md b/CONTRIBUTING.md
index 98800faf8..e3c6300b7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,6 +18,26 @@ On all platforms, the minimum requirements are:
- JetBrains Rider 2022.2+ or Visual Studio 2022+ or Visual Studio Code
- .NET Framework 4.6.2+
+### Development Containers
+
+This repository includes support for [Development Containers](https://containers.dev/) (devcontainers), which provide a consistent, containerized development environment. The devcontainer configuration includes all necessary dependencies and tools pre-configured.
+
+To use the devcontainer:
+
+1. **Prerequisites**: Install [Docker](https://www.docker.com/) and either:
+ - [Visual Studio Code](https://code.visualstudio.com/) with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
+ - [GitHub Codespaces](https://github.com/features/codespaces)
+
+2. **Using with VS Code**:
+ - Open the repository in VS Code
+ - When prompted, click "Reopen in Container" or use the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and select "Dev Containers: Reopen in Container"
+
+3. **Using with GitHub Codespaces**:
+ - Navigate to the repository on GitHub
+ - Click the "Code" button and select "Create codespace on [branch-name]"
+
+The devcontainer provides a pre-configured environment with the .NET SDK and all necessary tools for development and testing.
+
## Pull Request
All contributions to the OpenFeature project are welcome via GitHub pull requests.
@@ -130,6 +150,16 @@ Please make sure you follow the latest [conventions](https://www.conventionalcom
If you want to point out a breaking change, you should use `!` after the type. For example: `feat!: excellent new feature`.
+### Changelog Visibility and Release Triggers
+
+Only certain types are visible in the generated changelog:
+
+- `feat`: โจ New Features - New functionality added
+- `fix`: ๐ Bug Fixes - Bug fixes and corrections
+- `perf`: ๐ Performance - Performance improvements
+- `refactor`: ๐ง Refactoring - Code changes that neither fix bugs nor add features
+- `revert`: ๐ Reverts - Reverted changes
+
## Design Choices
As with other OpenFeature SDKs, dotnet-sdk follows the
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6bdfa4553..0f6a8448b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,26 +10,37 @@
-
-
-
-
+
+
+
+
+
+
+
-
+
-
+
-
+
+
-
+
+
+
@@ -39,4 +50,4 @@
-
\ No newline at end of file
+
diff --git a/OpenFeature.slnx b/OpenFeature.slnx
index d6778e50e..936079f40 100644
--- a/OpenFeature.slnx
+++ b/OpenFeature.slnx
@@ -5,7 +5,7 @@
-
+
@@ -14,9 +14,6 @@
-
-
-
@@ -51,18 +48,22 @@
-
-
+
+
+
-
+
+
+
+
-
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 5f1b725b5..4eb02b551 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.9.0)
[](https://cloud-native.slack.com/archives/C0344AANLA1)
[](https://codecov.io/gh/open-feature/dotnet-sdk)
@@ -33,6 +33,12 @@
Note that the packages will aim to support all current .NET versions. Refer to the currently supported versions [.NET](https://dotnet.microsoft.com/download/dotnet) and [.NET Framework](https://dotnet.microsoft.com/download/dotnet-framework) excluding .NET Framework 3.5
+### NativeAOT Support
+
+โ
**Full NativeAOT Compatibility** - The OpenFeature .NET SDK is fully compatible with .NET NativeAOT compilation for fast startup and small deployment size. See the [AOT Compatibility Guide](docs/AOT_COMPATIBILITY.md) for detailed instructions.
+
+> While the core OpenFeature SDK is fully NativeAOT compatible, contrib and community-provided providers, hooks, and extensions may not be. Please check with individual provider/hook documentation for their NativeAOT compatibility status.
+
### Install
Use the following to initialize your project:
@@ -79,25 +85,25 @@ 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) guide!
+Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide!
## ๐ Features
@@ -113,7 +119,8 @@ Want to contribute a new sample? See our [CONTRIBUTING](#-contributing) 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. |
+| ๐ฌ | [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,17 +440,106 @@ 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
+### Multi-Provider
> [!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.
+> The Multi-Provider feature is currently experimental.
+
+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.
+
+The Multi-Provider supports provider hooks and executes them in accordance with the OpenFeature specification. Each provider's hooks are executed with context isolation, ensuring that context modifications by one provider's hooks do not affect other providers.
+
+#### 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
+
+- **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]
+> The OpenFeature.Hosting package is currently experimental. The Hosting package streamlines the integration of OpenFeature within .NET applications, allowing for seamless configuration and lifecycle management of feature flag providers using dependency injection and hosting services.
#### Installation
-To set up dependency injection and hosting capabilities for OpenFeature, install the following packages:
+To set up dependency injection and hosting capabilities for OpenFeature, install the following package:
```sh
-dotnet add package OpenFeature.DependencyInjection
dotnet add package OpenFeature.Hosting
```
@@ -456,7 +552,6 @@ For a basic configuration, you can use the InMemoryProvider. This provider is si
```csharp
builder.Services.AddOpenFeature(featureBuilder => {
featureBuilder
- .AddHostedFeatureLifecycle() // From Hosting package
.AddInMemoryProvider();
});
```
@@ -478,9 +573,9 @@ builder.Services.AddOpenFeature(featureBuilder => {
```csharp
builder.Services.AddOpenFeature(featureBuilder => {
featureBuilder
- .AddHostedFeatureLifecycle()
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
.AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ ))
+ .AddHook(new MetricsHook())
.AddInMemoryProvider("name1")
.AddInMemoryProvider("name2")
.AddPolicyName(options => {
@@ -534,6 +629,171 @@ 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.
+
+You can specify custom tags on spans created by the `TraceEnricherHook` by providing `TraceEnricherHookOptions` when adding the hook:
+
+```csharp
+var options = TraceEnricherHookOptions.CreateBuilder()
+ .WithTag("custom_dimension_key", "custom_dimension_value")
+ .Build();
+
+OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook(options));
+```
+
+You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. The below example will add a tag to the span with the key `boolean` and a value specified by the callback.
+
+```csharp
+var options = TraceEnricherHookOptions.CreateBuilder()
+ .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
+ .Build();
+```
+
+### 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.
+
+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/build/Common.prod.props b/build/Common.prod.props
index 55264b3c0..f1a21cc26 100644
--- a/build/Common.prod.props
+++ b/build/Common.prod.props
@@ -1,5 +1,5 @@
-
+
true
@@ -9,7 +9,7 @@
- 2.6.0
+ 2.9.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.
@@ -24,8 +24,13 @@
$(VersionNumber)
+
+
+ true
+
+
-
+
diff --git a/docs/AOT_COMPATIBILITY.md b/docs/AOT_COMPATIBILITY.md
new file mode 100644
index 000000000..afa6f1e73
--- /dev/null
+++ b/docs/AOT_COMPATIBILITY.md
@@ -0,0 +1,152 @@
+# OpenFeature .NET SDK - NativeAOT Compatibility
+
+The OpenFeature .NET SDK is compatible with .NET NativeAOT compilation, allowing you to create self-contained, native executables with faster startup times and lower memory usage.
+
+## Compatibility Status
+
+**Fully Compatible** - The SDK can be used in NativeAOT applications without any issues.
+
+### What's AOT-Compatible
+
+- Core API functionality (`Api.Instance`, `GetClient()`, flag evaluations)
+- All built-in providers (`NoOpProvider`, etc.)
+- JSON serialization of `Value`, `Structure`, and `EvaluationContext`
+- Error handling and enum descriptions
+- Hook system
+- Event handling
+- Metrics collection
+- Dependency injection
+
+## Using OpenFeature with NativeAOT
+
+### 1. Project Configuration
+
+To enable NativeAOT in your project, add these properties to your `.csproj` file:
+
+```xml
+
+
+ net8.0
+ Exe
+
+
+ true
+
+
+
+
+
+
+```
+
+### 2. Basic Usage
+
+```csharp
+using OpenFeature;
+using OpenFeature.Model;
+
+// Basic OpenFeature usage - fully AOT compatible
+var api = Api.Instance;
+var client = api.GetClient("my-app");
+
+// All flag evaluation methods work
+var boolFlag = await client.GetBooleanValueAsync("feature-enabled", false);
+var stringFlag = await client.GetStringValueAsync("welcome-message", "Hello");
+var intFlag = await client.GetIntegerValueAsync("max-items", 10);
+```
+
+### 3. JSON Serialization (Recommended)
+
+For optimal AOT performance, use the provided `JsonSerializerContext`:
+
+```csharp
+using System.Text.Json;
+using OpenFeature.Model;
+using OpenFeature.Serialization;
+
+var value = new Value(Structure.Builder()
+ .Set("name", "test")
+ .Set("enabled", true)
+ .Build());
+
+// Use AOT-compatible serialization
+var json = JsonSerializer.Serialize(value, OpenFeatureJsonSerializerContext.Default.Value);
+var deserialized = JsonSerializer.Deserialize(json, OpenFeatureJsonSerializerContext.Default.Value);
+```
+
+### 4. Publishing for NativeAOT
+
+Build and publish your AOT application:
+
+```bash
+# Build with AOT analysis
+dotnet build -c Release
+
+# Publish as native executable
+dotnet publish -c Release
+
+# Run the native executable (example path for macOS ARM64)
+./bin/Release/net9.0/osx-arm64/publish/MyApp
+```
+
+## Performance Benefits
+
+NativeAOT compilation provides several benefits:
+
+- **Faster Startup**: Native executables start faster than JIT-compiled applications
+- **Lower Memory Usage**: Reduced memory footprint
+- **Self-Contained**: No .NET runtime dependency required
+- **Smaller Deployment**: Optimized for size with trimming
+
+## Testing AOT Compatibility
+
+The SDK includes an AOT compatibility test project at `test/OpenFeature.AotCompatibility/` that:
+
+- Tests all core SDK functionality
+- Validates JSON serialization with source generation
+- Verifies error handling works correctly
+- Can be compiled and run as a native executable
+
+Run the test:
+
+```bash
+cd test/OpenFeature.AotCompatibility
+dotnet publish -c Release
+./bin/Release/net9.0/[runtime]/publish/OpenFeature.AotCompatibility
+```
+
+## Limitations
+
+Currently, there are no known limitations when using OpenFeature with NativeAOT. All core functionality is fully supported.
+
+## Provider Compatibility
+
+When using third-party providers, ensure they are also AOT-compatible. Check the provider's documentation for AOT support.
+
+## Troubleshooting
+
+### Trimming Warnings
+
+If you encounter trimming warnings, you can:
+
+1. Use the provided `JsonSerializerContext` for JSON operations
+2. Ensure your providers are AOT-compatible
+3. Add appropriate `[DynamicallyAccessedMembers]` attributes if needed
+
+### Build Issues
+
+- Ensure you're targeting .NET 8.0 or later
+- Verify all dependencies support NativeAOT
+- Check that `PublishAot` is set to `true`
+
+## Migration Guide
+
+If migrating from a non-AOT setup:
+
+1. **JSON Serialization**: Replace direct `JsonSerializer` calls with the provided context
+2. **Reflection**: The SDK no longer uses reflection, but ensure your custom code doesn't
+3. **Dynamic Loading**: Avoid dynamic assembly loading; register providers at compile time
+
+## Example AOT Application
+
+See the complete example in `test/OpenFeature.AotCompatibility/Program.cs` for a working AOT application that demonstrates all SDK features.
diff --git a/nuget.config b/nuget.config
deleted file mode 100644
index 5a0edf435..000000000
--- a/nuget.config
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/release-please-config.json b/release-please-config.json
index 5a0201f6d..6baeed441 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -8,7 +8,8 @@
"versioning": "default",
"extra-files": [
"build/Common.prod.props",
- "README.md"
+ "README.md",
+ "src/OpenFeature.DependencyInjection/README.md"
]
}
},
@@ -23,10 +24,12 @@
},
{
"type": "chore",
+ "hidden": true,
"section": "๐งน Chore"
},
{
"type": "docs",
+ "hidden": true,
"section": "๐ Documentation"
},
{
@@ -40,6 +43,7 @@
},
{
"type": "deps",
+ "hidden": true,
"section": "๐ฆ Dependencies"
},
{
@@ -49,7 +53,7 @@
},
{
"type": "refactor",
- "section": "๐ Refactoring"
+ "section": "๐ง Refactoring"
},
{
"type": "revert",
diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs
index 462370861..3dc0203b1 100644
--- a/samples/AspNetCore/Program.cs
+++ b/samples/AspNetCore/Program.cs
@@ -1,23 +1,63 @@
+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.Hosting.Providers.Memory;
+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;
-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();
-builder.Services.AddOpenFeature(builder =>
+// 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 =>
{
- builder.AddHostedFeatureLifecycle()
+ var metricsHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
+ .Build();
+
+ featureBuilder
.AddHook(sp => new LoggingHook(sp.GetRequiredService>()))
- .AddInMemoryProvider("InMemory", provider => new Dictionary()
+ .AddHook(_ => new MetricsHook(metricsHookOptions))
+ .AddHook()
+ .AddInMemoryProvider("InMemory", _ => new Dictionary()
{
{
"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")
}
});
});
@@ -39,4 +79,74 @@
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.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();
+
+
+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 01e452d77..6945e6692 100644
--- a/samples/AspNetCore/Samples.AspNetCore.csproj
+++ b/samples/AspNetCore/Samples.AspNetCore.csproj
@@ -1,9 +1,21 @@
๏ปฟ
+
+ false
+ true
+ true
+
+
-
+
+
+
+
+
+
+
diff --git a/spec b/spec
index edf0debe0..969e11c4d 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit edf0debe0b4547d1f13e49f8e58a6d182237b43b
+Subproject commit 969e11c4d5df4ab16b400965ef1b3e313dcb923e
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/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj
index 855ab2ab2..9ae3029df 100644
--- a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj
+++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj
@@ -3,6 +3,7 @@
netstandard2.0;net8.0;net9.0;net462
OpenFeature.DependencyInjection
+ README.md
@@ -17,6 +18,7 @@
+
diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
index 317589606..d676dc5e9 100644
--- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
+++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
@@ -272,12 +272,51 @@ 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);
}
+ ///
+ /// 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<
+#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);
+ }
+
+ ///
+ /// 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<
+#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);
+ }
+
///
/// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound.
///
@@ -286,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.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs
index 74d01ad3a..a24c67e78 100644
--- a/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs
+++ b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs
@@ -24,7 +24,9 @@ public static IServiceCollection AddOpenFeature(this IServiceCollection services
Guard.ThrowIfNull(configure);
// Register core OpenFeature services as singletons.
- services.TryAddSingleton(Api.Instance);
+ var api = new Api();
+ Api.SetInstance(api);
+ services.TryAddSingleton(api);
services.TryAddSingleton();
var builder = new OpenFeatureBuilder(services);
diff --git a/src/OpenFeature.DependencyInjection/README.md b/src/OpenFeature.DependencyInjection/README.md
new file mode 100644
index 000000000..6b9fcfe72
--- /dev/null
+++ b/src/OpenFeature.DependencyInjection/README.md
@@ -0,0 +1,48 @@
+# OpenFeature.DependencyInjection
+
+> **โ ๏ธ DEPRECATED**: This library is now deprecated. The OpenTelemetry Dependency Injection library has been moved to the OpenFeature Hosting integration in version 2.9.0.
+
+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.
+
+## Migration Guide
+
+If you are using `OpenFeature.DependencyInjection`, you should migrate to the `OpenFeature.Hosting` package. The hosting package provides the same functionality but in one package.
+
+### 1. Update dependencies
+
+Remove this package:
+
+```xml
+
+```
+
+Update or install the latest `OpenFeature.Hosting` package:
+
+```xml
+
+```
+
+### 2. Update your `Program.cs`
+
+Remove the `AddHostedFeatureLifecycle` method call.
+
+#### Before
+
+```csharp
+builder.Services.AddOpenFeature(featureBuilder =>
+{
+ featureBuilder
+ .AddHostedFeatureLifecycle();
+
+ // Omit for code brevity
+});
+```
+
+#### After
+
+```csharp
+builder.Services.AddOpenFeature(featureBuilder =>
+{
+ // Omit for code brevity
+});
+```
diff --git a/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs b/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs
new file mode 100644
index 000000000..f7ecf81cb
--- /dev/null
+++ b/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs
@@ -0,0 +1,38 @@
+namespace OpenFeature.Hosting.Diagnostics;
+
+///
+/// Contains identifiers for experimental features and diagnostics in the OpenFeature framework.
+///
+///
+/// Experimental - This class includes identifiers that allow developers to track and conditionally enable
+/// experimental features. Each identifier follows a structured code format to indicate the feature domain,
+/// maturity level, and unique identifier. Note that experimental features are subject to change or removal
+/// in future releases.
+///
+/// Basic Information
+/// These identifiers conform to OpenFeatureโs Diagnostics Specifications, allowing developers to recognize
+/// and manage experimental features effectively.
+///
+///
+///
+///
+/// Code Structure:
+/// - "OF" - Represents the OpenFeature library.
+/// - "DI" - Indicates the Dependency Injection domain.
+/// - "001" - Unique identifier for a specific feature.
+///
+///
+internal static class FeatureCodes
+{
+ ///
+ /// Identifier for the experimental Dependency Injection features within the OpenFeature framework.
+ ///
+ ///
+ /// OFDI001 identifier marks experimental features in the Dependency Injection (DI) domain.
+ ///
+ /// Usage:
+ /// Developers can use this identifier to conditionally enable or test experimental DI features.
+ /// It is part of the OpenFeature diagnostics system to help track experimental functionality.
+ ///
+ public const string NewDi = "OFDI001";
+}
diff --git a/src/OpenFeature.Hosting/Guard.cs b/src/OpenFeature.Hosting/Guard.cs
new file mode 100644
index 000000000..2d37ef54d
--- /dev/null
+++ b/src/OpenFeature.Hosting/Guard.cs
@@ -0,0 +1,14 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace OpenFeature.Hosting;
+
+[DebuggerStepThrough]
+internal static class Guard
+{
+ public static void ThrowIfNull(object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
+ {
+ if (argument is null)
+ throw new ArgumentNullException(paramName);
+ }
+}
diff --git a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs
index 5209a5257..4411c21bb 100644
--- a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs
+++ b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs
@@ -1,7 +1,6 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using OpenFeature.DependencyInjection;
namespace OpenFeature.Hosting;
diff --git a/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs b/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs
new file mode 100644
index 000000000..54f791fbc
--- /dev/null
+++ b/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs
@@ -0,0 +1,24 @@
+namespace OpenFeature.Hosting;
+
+///
+/// Defines the contract for managing the lifecycle of a feature api.
+///
+public interface IFeatureLifecycleManager
+{
+ ///
+ /// Ensures that the feature provider is properly initialized and ready to be used.
+ /// This method should handle all necessary checks, configuration, and setup required to prepare the feature provider.
+ ///
+ /// Propagates notification that operations should be canceled.
+ /// A Task representing the asynchronous operation of initializing the feature provider.
+ /// Thrown when the feature provider is not registered or is in an invalid state.
+ ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gracefully shuts down the feature api, ensuring all resources are properly disposed of and any persistent state is saved.
+ /// This method should handle all necessary cleanup and shutdown operations for the feature provider.
+ ///
+ /// Propagates notification that operations should be canceled.
+ /// A Task representing the asynchronous operation of shutting down the feature provider.
+ ValueTask ShutdownAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs b/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs
new file mode 100644
index 000000000..34e000ce2
--- /dev/null
+++ b/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs
@@ -0,0 +1,8 @@
+using OpenFeature.Constant;
+using OpenFeature.Model;
+
+namespace OpenFeature.Hosting.Internal;
+
+internal record EventHandlerDelegateWrapper(
+ ProviderEventTypes ProviderEventType,
+ EventHandlerDelegate EventHandlerDelegate);
diff --git a/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs
new file mode 100644
index 000000000..4d915946b
--- /dev/null
+++ b/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs
@@ -0,0 +1,66 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace OpenFeature.Hosting.Internal;
+
+internal sealed partial class FeatureLifecycleManager : IFeatureLifecycleManager
+{
+ private readonly Api _featureApi;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ public FeatureLifecycleManager(Api featureApi, IServiceProvider serviceProvider, ILogger logger)
+ {
+ _featureApi = featureApi;
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ ///
+ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default)
+ {
+ this.LogStartingInitializationOfFeatureProvider();
+
+ var options = _serviceProvider.GetRequiredService>().Value;
+ if (options.HasDefaultProvider)
+ {
+ var featureProvider = _serviceProvider.GetRequiredService();
+ await _featureApi.SetProviderAsync(featureProvider).ConfigureAwait(false);
+ }
+
+ foreach (var name in options.ProviderNames)
+ {
+ var featureProvider = _serviceProvider.GetRequiredKeyedService(name);
+ await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false);
+ }
+
+ var hooks = new List();
+ foreach (var hookName in options.HookNames)
+ {
+ var hook = _serviceProvider.GetRequiredKeyedService(hookName);
+ hooks.Add(hook);
+ }
+
+ _featureApi.AddHooks(hooks);
+
+ var handlers = _serviceProvider.GetServices();
+ foreach (var handler in handlers)
+ {
+ _featureApi.AddHandler(handler.ProviderEventType, handler.EventHandlerDelegate);
+ }
+ }
+
+ ///
+ public async ValueTask ShutdownAsync(CancellationToken cancellationToken = default)
+ {
+ this.LogShuttingDownFeatureProvider();
+ await _featureApi.ShutdownAsync().ConfigureAwait(false);
+ }
+
+ [LoggerMessage(200, LogLevel.Information, "Starting initialization of the feature provider")]
+ partial void LogStartingInitializationOfFeatureProvider();
+
+ [LoggerMessage(200, LogLevel.Information, "Shutting down the feature provider")]
+ partial void LogShuttingDownFeatureProvider();
+}
diff --git a/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs b/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs
new file mode 100644
index 000000000..afbec6b06
--- /dev/null
+++ b/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs
@@ -0,0 +1,23 @@
+// @formatter:off
+// ReSharper disable All
+#if NETCOREAPP3_0_OR_GREATER
+// https://github.com/dotnet/runtime/issues/96197
+[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.CallerArgumentExpressionAttribute))]
+#else
+#pragma warning disable
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace System.Runtime.CompilerServices;
+
+[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
+internal sealed class CallerArgumentExpressionAttribute : Attribute
+{
+ public CallerArgumentExpressionAttribute(string parameterName)
+ {
+ ParameterName = parameterName;
+ }
+
+ public string ParameterName { get; }
+}
+#endif
diff --git a/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs b/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs
new file mode 100644
index 000000000..877141115
--- /dev/null
+++ b/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs
@@ -0,0 +1,21 @@
+// @formatter:off
+// ReSharper disable All
+#if NET5_0_OR_GREATER
+// https://github.com/dotnet/runtime/issues/96197
+[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))]
+#else
+#pragma warning disable
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel;
+
+namespace System.Runtime.CompilerServices;
+
+///
+/// Reserved to be used by the compiler for tracking metadata.
+/// This class should not be used by developers in source code.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+static class IsExternalInit { }
+#endif
diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj
index 1d54ff02e..84e5efa61 100644
--- a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj
+++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj
@@ -1,8 +1,9 @@
-
+๏ปฟ
- net8.0;net9.0
+ netstandard2.0;net8.0;net9.0;net462
OpenFeature
+ README.md
@@ -10,7 +11,13 @@
-
+
+
+
+
+
+
+
diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilder.cs b/src/OpenFeature.Hosting/OpenFeatureBuilder.cs
new file mode 100644
index 000000000..177a9fac3
--- /dev/null
+++ b/src/OpenFeature.Hosting/OpenFeatureBuilder.cs
@@ -0,0 +1,60 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace OpenFeature.Hosting;
+
+///
+/// Describes a backed by an .
+///
+/// The services being configured.
+public class OpenFeatureBuilder(IServiceCollection services)
+{
+ /// The services being configured.
+ public IServiceCollection Services { get; } = services;
+
+ ///
+ /// Indicates whether the evaluation context has been configured.
+ /// This property is used to determine if specific configurations or services
+ /// should be initialized based on the presence of an evaluation context.
+ ///
+ public bool IsContextConfigured { get; internal set; }
+
+ ///
+ /// Indicates whether the policy has been configured.
+ ///
+ public bool IsPolicyConfigured { get; internal set; }
+
+ ///
+ /// Gets a value indicating whether a default provider has been registered.
+ ///
+ public bool HasDefaultProvider { get; internal set; }
+
+ ///
+ /// Gets the count of domain-bound providers that have been registered.
+ /// This count does not include the default provider.
+ ///
+ public int DomainBoundProviderRegistrationCount { get; internal set; }
+
+ ///
+ /// Validates the current configuration, ensuring that a policy is set when multiple providers are registered
+ /// or when a default provider is registered alongside another provider.
+ ///
+ ///
+ /// Thrown if multiple providers are registered without a policy, or if both a default provider
+ /// and an additional provider are registered without a policy configuration.
+ ///
+ public void Validate()
+ {
+ if (!IsPolicyConfigured)
+ {
+ if (DomainBoundProviderRegistrationCount > 1)
+ {
+ throw new InvalidOperationException("Multiple providers have been registered, but no policy has been configured.");
+ }
+
+ if (HasDefaultProvider && DomainBoundProviderRegistrationCount == 1)
+ {
+ throw new InvalidOperationException("A default provider and an additional provider have been registered without a policy configuration.");
+ }
+ }
+ }
+}
diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs
index 80e760d9d..52c66c42e 100644
--- a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs
+++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs
@@ -1,6 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
-using OpenFeature.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using OpenFeature.Constant;
using OpenFeature.Hosting;
+using OpenFeature.Hosting.Internal;
+using OpenFeature.Model;
namespace OpenFeature;
@@ -9,6 +13,370 @@ namespace OpenFeature;
///
public static partial class OpenFeatureBuilderExtensions
{
+ ///
+ /// This method is used to add a new context to the service collection.
+ ///
+ /// The instance.
+ /// the desired configuration
+ /// The instance.
+ /// Thrown when the or action is null.
+ public static OpenFeatureBuilder AddContext(this OpenFeatureBuilder builder, Action configure)
+ {
+ Guard.ThrowIfNull(builder);
+ Guard.ThrowIfNull(configure);
+
+ return builder.AddContext((b, _) => configure(b));
+ }
+
+ ///
+ /// This method is used to add a new context to the service collection.
+ ///
+ /// The instance.
+ /// the desired configuration
+ /// The instance.
+ /// Thrown when the or action is null.
+ public static OpenFeatureBuilder AddContext(this OpenFeatureBuilder builder, Action configure)
+ {
+ Guard.ThrowIfNull(builder);
+ Guard.ThrowIfNull(configure);
+
+ builder.IsContextConfigured = true;
+ builder.Services.TryAddTransient(provider =>
+ {
+ var contextBuilder = EvaluationContext.Builder();
+ configure(contextBuilder, provider);
+ return contextBuilder.Build();
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Adds a feature provider using a factory method without additional configuration options.
+ /// This method adds the feature provider as a transient service and sets it as the default provider within the application.
+ ///
+ /// The used to configure feature flags.
+ ///
+ /// A factory method that creates and returns a
+ /// instance based on the provided service provider.
+ ///
+ /// The updated instance with the default feature provider set and configured.
+ /// Thrown if the is null, as a valid builder is required to add and configure providers.
+ public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory)
+ => AddProvider(builder, implementationFactory, null);
+
+ ///
+ /// Adds a feature provider using a factory method to create the provider instance and optionally configures its settings.
+ /// This method adds the feature provider as a transient service and sets it as the default provider within the application.
+ ///
+ /// Type derived from used to configure the feature provider.
+ /// The used to configure feature flags.
+ ///
+ /// A factory method that creates and returns a
+ /// instance based on the provided service provider.
+ ///
+ /// An optional delegate to configure the provider-specific options.
+ /// The updated instance with the default feature provider set and configured.
+ /// Thrown if the is null, as a valid builder is required to add and configure providers.
+ public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory, Action? configureOptions)
+ where TOptions : OpenFeatureOptions
+ {
+ Guard.ThrowIfNull(builder);
+
+ builder.HasDefaultProvider = true;
+ builder.Services.PostConfigure(options => options.AddDefaultProviderName());
+ if (configureOptions != null)
+ {
+ builder.Services.Configure(configureOptions);
+ }
+
+ builder.Services.TryAddTransient(implementationFactory);
+ builder.AddClient();
+ return builder;
+ }
+
+ ///
+ /// Adds a feature provider for a specific domain using provided options and a configuration builder.
+ ///
+ /// Type derived from used to configure the feature provider.
+ /// The used to configure feature flags.
+ /// The unique name of the provider.
+ ///
+ /// A factory method that creates a feature provider instance.
+ /// It adds the provider as a transient service unless it is already added.
+ ///
+ /// An optional delegate to configure the provider-specific options.
+ /// The updated instance with the new feature provider configured.
+ ///
+ /// Thrown if either or is null or if the is empty.
+ ///
+ public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory, Action? configureOptions)
+ where TOptions : OpenFeatureOptions
+ {
+ Guard.ThrowIfNull(builder);
+
+ builder.DomainBoundProviderRegistrationCount++;
+
+ builder.Services.PostConfigure(options => options.AddProviderName(domain));
+ if (configureOptions != null)
+ {
+ builder.Services.Configure(domain, configureOptions);
+ }
+
+ builder.Services.TryAddKeyedTransient(domain, (provider, key) =>
+ {
+ if (key == null)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+ return implementationFactory(provider, key.ToString()!);
+ });
+
+ builder.AddClient(domain);
+ return builder;
+ }
+
+ ///
+ /// Adds a feature provider for a specified domain using the default options.
+ /// This method configures a feature provider without custom options, delegating to the more generic AddProvider method.
+ ///
+ /// The used to configure feature flags.
+ /// The unique name of the provider.
+ ///
+ /// A factory method that creates a feature provider instance.
+ /// It adds the provider as a transient service unless it is already added.
+ ///
+ /// The updated instance with the new feature provider configured.
+ ///
+ /// Thrown if either or is null or if the is empty.
+ ///
+ public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory)
+ => AddProvider(builder, domain, implementationFactory, configureOptions: null);
+
+ ///
+ /// Adds a feature client to the service collection, configuring it to work with a specific context if provided.
+ ///
+ /// The instance.
+ /// Optional: The name for the feature client instance.
+ /// The instance.
+ internal static OpenFeatureBuilder AddClient(this OpenFeatureBuilder builder, string? name = null)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ if (builder.IsContextConfigured)
+ {
+ builder.Services.TryAddScoped(static provider =>
+ {
+ var api = provider.GetRequiredService();
+ var client = api.GetClient();
+ var context = provider.GetRequiredService();
+ client.SetContext(context);
+ return client;
+ });
+ }
+ else
+ {
+ builder.Services.TryAddScoped(static provider =>
+ {
+ var api = provider.GetRequiredService();
+ return api.GetClient();
+ });
+ }
+ }
+ else
+ {
+ if (builder.IsContextConfigured)
+ {
+ builder.Services.TryAddKeyedScoped(name, static (provider, key) =>
+ {
+ var api = provider.GetRequiredService();
+ var client = api.GetClient(key!.ToString());
+ var context = provider.GetRequiredService();
+ client.SetContext(context);
+ return client;
+ });
+ }
+ else
+ {
+ builder.Services.TryAddKeyedScoped(name, static (provider, key) =>
+ {
+ var api = provider.GetRequiredService();
+ return api.GetClient(key!.ToString());
+ });
+ }
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Adds a default to the based on the policy name options.
+ /// This method configures the dependency injection container to resolve the appropriate
+ /// depending on the policy name selected.
+ /// If no name is selected (i.e., null), it retrieves the default client.
+ ///
+ /// The instance.
+ /// The configured instance.
+ internal static OpenFeatureBuilder AddPolicyBasedClient(this OpenFeatureBuilder builder)
+ {
+ builder.Services.AddScoped(provider =>
+ {
+ var policy = provider.GetRequiredService>().Value;
+ var name = policy.DefaultNameSelector(provider);
+ if (name == null)
+ {
+ return provider.GetRequiredService();
+ }
+ return provider.GetRequiredKeyedService(name);
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Configures policy name options for OpenFeature using the specified options type.
+ ///
+ /// The type of options used to configure .
+ /// The instance.
+ /// A delegate to configure .
+ /// The configured instance.
+ /// Thrown when the or is null.
+ public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions)
+ where TOptions : PolicyNameOptions
+ {
+ Guard.ThrowIfNull(builder);
+ Guard.ThrowIfNull(configureOptions);
+
+ builder.IsPolicyConfigured = true;
+
+ builder.Services.Configure(configureOptions);
+ return builder;
+ }
+
+ ///
+ /// Configures the default policy name options for OpenFeature.
+ ///
+ /// The instance.
+ /// A delegate to configure .
+ /// The configured instance.
+ public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions)
+ => AddPolicyName(builder, configureOptions);
+
+ ///
+ /// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound.
+ ///
+ /// The type of to be added.
+ /// The instance.
+ /// Optional factory for controlling how will be created in the DI container.
+ /// The instance.
+ 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);
+ }
+
+ ///
+ /// 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<
+#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);
+ }
+
+ ///
+ /// 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<
+#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);
+ }
+
+ ///
+ /// Adds a feature hook to the service collection using a factory method and 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.
+ /// Optional factory for controlling how will be created in the DI container.
+ /// The instance.
+ 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));
+
+ if (implementationFactory is not null)
+ {
+ builder.Services.TryAddKeyedSingleton(hookName, (serviceProvider, key) =>
+ {
+ return implementationFactory(serviceProvider);
+ });
+ }
+ else
+ {
+ builder.Services.TryAddKeyedSingleton(hookName);
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions
+ ///
+ /// The instance.
+ /// The type to handle.
+ /// The handler which reacts to .
+ /// The instance.
+ public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, EventHandlerDelegate eventHandlerDelegate)
+ {
+ return AddHandler(builder, type, _ => eventHandlerDelegate);
+ }
+
+ ///
+ /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions
+ ///
+ /// The instance.
+ /// The type to handle.
+ /// The handler factory for creating a handler which reacts to .
+ /// The instance.
+ public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, Func implementationFactory)
+ {
+ builder.Services.AddSingleton((serviceProvider) =>
+ {
+ var handler = implementationFactory(serviceProvider);
+ return new EventHandlerDelegateWrapper(type, handler);
+ });
+
+ return builder;
+ }
+
///
/// Adds the to the OpenFeatureBuilder,
/// which manages the lifecycle of features within the application. It also allows
@@ -17,6 +385,7 @@ public static partial class OpenFeatureBuilderExtensions
/// The instance.
/// An optional action to configure .
/// The instance.
+ [Obsolete("Calling AddHostedFeatureLifecycle() is no longer necessary. OpenFeature will inject this automatically when you call AddOpenFeature().")]
public static OpenFeatureBuilder AddHostedFeatureLifecycle(this OpenFeatureBuilder builder, Action? configureOptions = null)
{
if (configureOptions is not null)
diff --git a/src/OpenFeature.Hosting/OpenFeatureOptions.cs b/src/OpenFeature.Hosting/OpenFeatureOptions.cs
new file mode 100644
index 000000000..9d3dd818e
--- /dev/null
+++ b/src/OpenFeature.Hosting/OpenFeatureOptions.cs
@@ -0,0 +1,61 @@
+namespace OpenFeature.Hosting;
+
+///
+/// Options to configure OpenFeature
+///
+public class OpenFeatureOptions
+{
+ private readonly HashSet _providerNames = [];
+
+ ///
+ /// Determines if a default provider has been registered.
+ ///
+ public bool HasDefaultProvider { get; private set; }
+
+ ///
+ /// The type of the configured feature provider.
+ ///
+ public Type FeatureProviderType { get; protected internal set; } = null!;
+
+ ///
+ /// Gets a read-only list of registered provider names.
+ ///
+ public IReadOnlyCollection ProviderNames => _providerNames;
+
+ ///
+ /// Registers the default provider name if no specific name is provided.
+ /// Sets to true.
+ ///
+ protected internal void AddDefaultProviderName() => AddProviderName(null);
+
+ ///
+ /// Registers a new feature provider name. This operation is thread-safe.
+ ///
+ /// The name of the feature provider to register. Registers as default if null.
+ protected internal void AddProviderName(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ HasDefaultProvider = true;
+ }
+ else
+ {
+ lock (_providerNames)
+ {
+ _providerNames.Add(name!);
+ }
+ }
+ }
+
+ private readonly HashSet _hookNames = [];
+
+ internal IReadOnlyCollection HookNames => _hookNames;
+
+ internal void AddHookName(string name)
+ {
+ lock (_hookNames)
+ {
+ _hookNames.Add(name);
+ }
+ }
+}
diff --git a/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs
new file mode 100644
index 000000000..236dc62b0
--- /dev/null
+++ b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using OpenFeature.Hosting;
+using OpenFeature.Hosting.Internal;
+
+namespace OpenFeature;
+
+///
+/// Contains extension methods for the class.
+///
+public static partial class OpenFeatureServiceCollectionExtensions
+{
+ ///
+ /// Adds and configures OpenFeature services to the provided .
+ ///
+ /// The instance.
+ /// A configuration action for customizing OpenFeature setup via
+ /// The modified instance
+ /// Thrown if or is null.
+ public static IServiceCollection AddOpenFeature(this IServiceCollection services, Action configure)
+ {
+ Guard.ThrowIfNull(services);
+ Guard.ThrowIfNull(configure);
+
+ // Register core OpenFeature services as singletons.
+ services.TryAddSingleton(Api.Instance);
+ services.TryAddSingleton();
+
+ var builder = new OpenFeatureBuilder(services);
+ configure(builder);
+
+ builder.Services.AddHostedService();
+
+ // If a default provider is specified without additional providers,
+ // return early as no extra configuration is needed.
+ if (builder.HasDefaultProvider && builder.DomainBoundProviderRegistrationCount == 0)
+ {
+ return services;
+ }
+
+ // Validate builder configuration to ensure consistency and required setup.
+ builder.Validate();
+
+ if (!builder.IsPolicyConfigured)
+ {
+ // Add a default name selector policy to use the first registered provider name as the default.
+ builder.AddPolicyName(options =>
+ {
+ options.DefaultNameSelector = provider =>
+ {
+ var options = provider.GetRequiredService>().Value;
+ return options.ProviderNames.First();
+ };
+ });
+ }
+
+ builder.AddPolicyBasedClient();
+
+ return services;
+ }
+}
diff --git a/src/OpenFeature.Hosting/PolicyNameOptions.cs b/src/OpenFeature.Hosting/PolicyNameOptions.cs
new file mode 100644
index 000000000..3dfa76f89
--- /dev/null
+++ b/src/OpenFeature.Hosting/PolicyNameOptions.cs
@@ -0,0 +1,12 @@
+namespace OpenFeature.Hosting;
+
+///
+/// Options to configure the default feature client name.
+///
+public class PolicyNameOptions
+{
+ ///
+ /// A delegate to select the default feature client name.
+ ///
+ public Func DefaultNameSelector { get; set; } = null!;
+}
diff --git a/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs
new file mode 100644
index 000000000..d63009d62
--- /dev/null
+++ b/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs
@@ -0,0 +1,126 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using OpenFeature.Providers.Memory;
+
+namespace OpenFeature.Hosting.Providers.Memory;
+
+///
+/// Extension methods for configuring feature providers with .
+///
+#if NET8_0_OR_GREATER
+[System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)]
+#endif
+public static partial class FeatureBuilderExtensions
+{
+ ///
+ /// Adds an in-memory feature provider to the with a factory for flags.
+ ///
+ /// The instance to configure.
+ ///
+ /// A factory function to provide an of flags.
+ /// If null, an empty provider will be created.
+ ///
+ /// The instance for chaining.
+ public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Func?> flagsFactory)
+ => builder.AddProvider(provider =>
+ {
+ var flags = flagsFactory(provider);
+ if (flags == null)
+ {
+ return new InMemoryProvider();
+ }
+
+ return new InMemoryProvider(flags);
+ });
+
+ ///
+ /// Adds an in-memory feature provider to the with a domain and factory for flags.
+ ///
+ /// The instance to configure.
+ /// The unique domain of the provider.
+ ///
+ /// A factory function to provide an of flags.
+ /// If null, an empty provider will be created.
+ ///
+ /// The instance for chaining.
+ public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory)
+ => builder.AddInMemoryProvider(domain, (provider, _) => flagsFactory(provider));
+
+ ///
+ /// Adds an in-memory feature provider to the with a domain and contextual flag factory.
+ /// If null, an empty provider will be created.
+ ///
+ /// The instance to configure.
+ /// The unique domain of the provider.
+ ///
+ /// A factory function to provide an of flags based on service provider and domain.
+ ///
+ /// The instance for chaining.
+ public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory)
+ => builder.AddProvider(domain, (provider, key) =>
+ {
+ var flags = flagsFactory(provider, key);
+ if (flags == null)
+ {
+ return new InMemoryProvider();
+ }
+
+ return new InMemoryProvider(flags);
+ });
+
+ ///
+ /// Adds an in-memory feature provider to the with optional flag configuration.
+ ///
+ /// The instance to configure.
+ ///
+ /// An optional delegate to configure feature flags in the in-memory provider.
+ /// If null, an empty provider will be created.
+ ///
+ /// The instance for chaining.
+ public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Action>? configure = null)
+ => builder.AddProvider(CreateProvider, options => ConfigureFlags(options, configure));
+
+ ///
+ /// Adds an in-memory feature provider with a specific domain to the with optional flag configuration.
+ ///
+ /// The instance to configure.
+ /// The unique domain of the provider
+ ///
+ /// An optional delegate to configure feature flags in the in-memory provider.
+ /// If null, an empty provider will be created.
+ ///
+ /// The instance for chaining.
+ public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Action>? configure = null)
+ => builder.AddProvider(domain, CreateProvider, options => ConfigureFlags(options, configure));
+
+ private static FeatureProvider CreateProvider(IServiceProvider provider, string domain)
+ {
+ var options = provider.GetRequiredService>().Get(domain);
+ if (options.Flags == null)
+ {
+ return new InMemoryProvider();
+ }
+
+ return new InMemoryProvider(options.Flags);
+ }
+
+ private static FeatureProvider CreateProvider(IServiceProvider provider)
+ {
+ var options = provider.GetRequiredService>().Value;
+ if (options.Flags == null)
+ {
+ return new InMemoryProvider();
+ }
+
+ return new InMemoryProvider(options.Flags);
+ }
+
+ private static void ConfigureFlags(InMemoryProviderOptions options, Action>? configure)
+ {
+ if (configure != null)
+ {
+ options.Flags = new Dictionary();
+ configure.Invoke(options.Flags);
+ }
+ }
+}
diff --git a/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs b/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs
new file mode 100644
index 000000000..3e7431eef
--- /dev/null
+++ b/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs
@@ -0,0 +1,19 @@
+using OpenFeature.Providers.Memory;
+
+namespace OpenFeature.Hosting.Providers.Memory;
+
+///
+/// Options for configuring the in-memory feature flag provider.
+///
+public class InMemoryProviderOptions : OpenFeatureOptions
+{
+ ///
+ /// Gets or sets the feature flags to be used by the in-memory provider.
+ ///
+ ///
+ /// This property allows you to specify a dictionary of flags where the key is the flag name
+ /// and the value is the corresponding instance.
+ /// If no flags are provided, the in-memory provider will start with an empty set of flags.
+ ///
+ public IDictionary? Flags { get; set; }
+}
diff --git a/src/OpenFeature.Hosting/README.md b/src/OpenFeature.Hosting/README.md
new file mode 100644
index 000000000..3b530d214
--- /dev/null
+++ b/src/OpenFeature.Hosting/README.md
@@ -0,0 +1,141 @@
+# OpenFeature.Hosting
+
+[](https://www.nuget.org/packages/OpenFeature.Hosting)
+[](https://github.com/open-feature/spec/releases/tag/v0.8.0)
+
+OpenFeature.Hosting is an extension for the [OpenFeature .NET SDK](https://github.com/open-feature/dotnet-sdk) that streamlines integration with .NET applications using dependency injection and hosting. It enables seamless configuration and lifecycle management of feature flag providers, hooks, and evaluation context using idiomatic .NET patterns.
+
+**๐งช The OpenFeature.Hosting package is still considered experimental and may undergo significant changes. Feedback and contributions are welcome!**
+
+## ๐ Quick Start
+
+### Requirements
+
+- .NET 8+
+- .NET Framework 4.6.2+
+
+### Installation
+
+Add the package to your project:
+
+```sh
+dotnet add package OpenFeature.Hosting
+```
+
+### Basic Usage
+
+Register OpenFeature in your application's dependency injection container (e.g., in `Program.cs` for ASP.NET Core):
+
+```csharp
+builder.Services.AddOpenFeature(featureBuilder => {
+ featureBuilder
+ .AddInMemoryProvider();
+});
+```
+
+You can add global evaluation context, hooks, and event handlers as needed:
+
+```csharp
+builder.Services.AddOpenFeature(featureBuilder => {
+ featureBuilder
+ .AddContext((contextBuilder, serviceProvider) => {
+ // Custom context configuration
+ })
+ .AddHook()
+ .AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) => {
+ // Handle provider ready event
+ });
+});
+```
+
+### Domain-Scoped Providers
+
+To register multiple providers and select a default provider by domain:
+
+```csharp
+builder.Services.AddOpenFeature(featureBuilder => {
+ featureBuilder
+ .AddInMemoryProvider("default")
+ .AddInMemoryProvider("beta")
+ .AddPolicyName(options => {
+ options.DefaultNameSelector = serviceProvider => "default";
+ });
+});
+```
+
+### Registering a Custom Provider
+
+You can register a custom provider using a factory:
+
+```csharp
+builder.Services.AddOpenFeature(featureBuilder => {
+ featureBuilder.AddProvider(provider => {
+ // Resolve services or configuration as needed
+ return new MyCustomProvider();
+ });
+});
+```
+
+## ๐งฉ Features
+
+- **Dependency Injection**: Register providers, hooks, and context using the .NET DI container.
+- **Domain Support**: Assign providers to logical domains for multi-tenancy or environment separation.
+- **Event Handlers**: React to provider lifecycle events (e.g., readiness).
+- **Extensibility**: Add custom hooks, context, and providers.
+
+## ๐ ๏ธ Example: ASP.NET Core Integration
+
+Below is a simple example of integrating OpenFeature with an ASP.NET Core application using an in-memory provider and a logging hook.
+
+```csharp
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddOpenFeature(featureBuilder => {
+ featureBuilder
+ .AddInMemoryProvider()
+ .AddHook();
+});
+
+var app = builder.Build();
+
+app.MapGet("/", async (IFeatureClient client) => {
+ bool enabled = await client.GetBooleanValueAsync("my-flag", false);
+ return enabled ? "Feature enabled!" : "Feature disabled.";
+});
+
+app.Run();
+```
+
+If you have multiple providers registered, you can specify which client and provider to resolve by using the `FromKeyedServices` attribute:
+
+```csharp
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddOpenFeature(featureBuilder => {
+ featureBuilder
+ .AddInMemoryProvider("default")
+ .AddInMemoryProvider("beta")
+ .AddPolicyName(options => {
+ options.DefaultNameSelector = serviceProvider => "default";
+ });
+});
+
+var app = builder.Build();
+
+app.MapGet("/", async ([FromKeyedServices("beta")] IFeatureClient client) => {
+ bool enabled = await client.GetBooleanValueAsync("my-flag", false);
+ return enabled ? "Feature enabled!" : "Feature disabled.";
+});
+
+app.Run();
+```
+
+## ๐ Further Reading
+
+- [OpenFeature .NET SDK Documentation](https://github.com/open-feature/dotnet-sdk)
+- [OpenFeature Specification](https://openfeature.dev)
+- [Samples](https://github.com/open-feature/dotnet-sdk/blob/main/samples/AspNetCore/README.md)
+
+## ๐ค Contributing
+
+Contributions are welcome! See the [CONTRIBUTING](https://github.com/open-feature/dotnet-sdk/blob/main/CONTRIBUTING.md) guide for details.
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..9737198f0
--- /dev/null
+++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs
@@ -0,0 +1,641 @@
+using System.Collections.Concurrent;
+using System.Collections.ObjectModel;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+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 partial 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 readonly object _providerStatusLock = new();
+ 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;
+
+ // Event handling infrastructure
+ private readonly ConcurrentDictionary _eventListeningTasks = new();
+ private readonly CancellationTokenSource _eventProcessingCancellation = new();
+ private readonly ILogger _logger;
+
+ ///
+ /// 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.
+ /// The logger for the client.
+ public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null, ILogger? logger = 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);
+
+ // Start listening to events from all registered providers
+ this.StartListeningToProviderEvents();
+
+ // Set logger
+ this._logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ public override Metadata GetMetadata() => this._metadata;
+
+ ///
+ internal override ProviderStatus Status
+ {
+ get
+ {
+ lock (this._providerStatusLock)
+ {
+ return this._providerStatus;
+ }
+ }
+ set
+ {
+ lock (this._providerStatusLock)
+ {
+ this._providerStatus = value;
+ }
+ }
+ }
+
+ ///
+ 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.Status != 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.Status = ProviderStatus.Fatal;
+
+ // Emit error event
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = this._metadata.Name,
+ Type = ProviderEventTypes.ProviderError,
+ Message = $"Failed to initialize providers: {string.Join(", ", failedProviders)}",
+ ErrorType = ErrorType.ProviderFatal
+ }, cancellationToken).ConfigureAwait(false);
+
+ throw new AggregateException(
+ $"Failed to initialize providers: {string.Join(", ", failedProviders)}",
+ exceptions);
+ }
+ else
+ {
+ this.Status = ProviderStatus.Ready;
+
+ // Emit ready event
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = this._metadata.Name,
+ Type = ProviderEventTypes.ProviderReady,
+ Message = "MultiProvider successfully initialized"
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ 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));
+ }
+
+ try
+ {
+ 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;
+ }
+ catch (NotSupportedException ex)
+ {
+ // Emit error event for unsupported run mode
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = this._metadata.Name,
+ Type = ProviderEventTypes.ProviderError,
+ Message = $"Error evaluating flag '{key}' with run mode {this._evaluationStrategy.RunMode}",
+ ErrorType = ErrorType.ProviderFatal
+ }, cancellationToken).ConfigureAwait(false);
+
+ return new ResolutionDetails(key, defaultValue, ErrorType.ProviderFatal, Reason.Error, errorMessage: ex.Message);
+ }
+ catch (Exception ex)
+ {
+ // Emit error event for evaluation failures
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = this._metadata.Name,
+ Type = ProviderEventTypes.ProviderError,
+ Message = $"Error evaluating flag '{key}': {ex.Message}",
+ ErrorType = ErrorType.General,
+ FlagsChanged = [key]
+ }, cancellationToken).ConfigureAwait(false);
+
+ return new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: ex.Message);
+ }
+ }
+
+ 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, this._logger, 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, this._logger, cancellationToken));
+ }
+ }
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ resolutions.AddRange(results);
+
+ return resolutions;
+ }
+
+ private void StartListeningToProviderEvents()
+ {
+ foreach (var registeredProvider in this._registeredProviders)
+ {
+ if (!this._eventListeningTasks.TryAdd(registeredProvider.Provider, this.ProcessProviderEventsAsync(registeredProvider)))
+ {
+ // Log a warning if the provider is already being listened to
+ this.LogProviderAlreadyBeingListenedTo(registeredProvider.Name);
+ }
+ }
+ }
+
+ private async Task ProcessProviderEventsAsync(RegisteredProvider registeredProvider)
+ {
+ var eventChannel = registeredProvider.Provider.GetEventChannel();
+
+ // Get the cancellation token safely for this provider's event processing (this prevents ObjectDisposedException during concurrent shutdown)
+ CancellationToken cancellationToken;
+ try
+ {
+ cancellationToken = this._eventProcessingCancellation.Token;
+ }
+ catch (ObjectDisposedException)
+ {
+ // Already disposed, exit early
+ return;
+ }
+
+ while (await eventChannel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ while (eventChannel.Reader.TryRead(out var item))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (item is not Event { EventPayload: { } eventPayload })
+ {
+ continue;
+ }
+
+ await this.HandleProviderEventAsync(registeredProvider, eventPayload, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task HandleProviderEventAsync(RegisteredProvider registeredProvider, ProviderEventPayload eventPayload, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Handle PROVIDER_CONFIGURATION_CHANGED events specially - these are always re-emitted
+ if (eventPayload.Type == ProviderEventTypes.ProviderConfigurationChanged)
+ {
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = $"{this._metadata.Name}/{registeredProvider.Name}",
+ Type = eventPayload.Type,
+ Message = eventPayload.Message ?? $"Configuration changed in provider {registeredProvider.Name}",
+ FlagsChanged = eventPayload.FlagsChanged,
+ EventMetadata = eventPayload.EventMetadata
+ }, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ // For status-changing events, update provider status and check if MultiProvider status should change
+ UpdateProviderStatusFromEvent(registeredProvider, eventPayload);
+
+ // Check if MultiProvider status has changed due to this provider's status change
+ var providerStatuses = this._registeredProviders.Select(rp => rp.Status).ToList();
+ var newMultiProviderStatus = DetermineAggregateStatus(providerStatuses);
+
+ ProviderStatus previousStatus;
+ ProviderEventTypes? eventType = null;
+
+ // Only emit event if MultiProvider status actually changed
+ lock (this._providerStatusLock)
+ {
+ if (newMultiProviderStatus != this._providerStatus)
+ {
+ previousStatus = this._providerStatus;
+ this._providerStatus = newMultiProviderStatus;
+
+ eventType = newMultiProviderStatus switch
+ {
+ ProviderStatus.Ready => ProviderEventTypes.ProviderReady,
+ ProviderStatus.Error or ProviderStatus.Fatal => ProviderEventTypes.ProviderError,
+ ProviderStatus.Stale => ProviderEventTypes.ProviderStale,
+ _ => (ProviderEventTypes?)null
+ };
+ }
+ else
+ {
+ return; // No status change, no event to emit
+ }
+ }
+
+ if (eventType.HasValue)
+ {
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = this._metadata.Name,
+ Type = eventType.Value,
+ Message = $"MultiProvider status changed from {previousStatus} to {newMultiProviderStatus} due to provider {registeredProvider.Name}",
+ ErrorType = newMultiProviderStatus == ProviderStatus.Fatal ? ErrorType.ProviderFatal : eventPayload.ErrorType,
+ FlagsChanged = eventPayload.FlagsChanged,
+ EventMetadata = eventPayload.EventMetadata
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ // If there's an error processing the event, emit an error event
+ await this.EmitEvent(new ProviderEventPayload
+ {
+ ProviderName = this._metadata.Name,
+ Type = ProviderEventTypes.ProviderError,
+ Message = $"Error processing event from provider {registeredProvider.Name}: {ex.Message}",
+ ErrorType = ErrorType.General
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private static void UpdateProviderStatusFromEvent(RegisteredProvider registeredProvider, ProviderEventPayload eventPayload)
+ {
+ var newStatus = eventPayload.Type switch
+ {
+ ProviderEventTypes.ProviderReady => ProviderStatus.Ready,
+ ProviderEventTypes.ProviderError => eventPayload.ErrorType == ErrorType.ProviderFatal
+ ? ProviderStatus.Fatal
+ : ProviderStatus.Error,
+ ProviderEventTypes.ProviderStale => ProviderStatus.Stale,
+ _ => registeredProvider.Status // No status change for PROVIDER_CONFIGURATION_CHANGED
+ };
+
+ if (newStatus != registeredProvider.Status)
+ {
+ registeredProvider.SetStatus(newStatus);
+ }
+ }
+
+ private async Task EmitEvent(ProviderEventPayload eventPayload, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await this.EventChannel.Writer.WriteAsync(eventPayload, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // If we can't write to the event channel (e.g., it's closed), ignore the error
+ }
+ }
+
+ private static ProviderStatus DetermineAggregateStatus(List providerStatuses)
+ {
+ // Check in precedence order as per specification
+ if (providerStatuses.Any(status => status == ProviderStatus.Fatal))
+ {
+ return ProviderStatus.Fatal;
+ }
+
+ if (providerStatuses.Any(status => status == ProviderStatus.NotReady))
+ {
+ return ProviderStatus.NotReady;
+ }
+
+ if (providerStatuses.Any(status => status == ProviderStatus.Error))
+ {
+ return ProviderStatus.Error;
+ }
+
+ if (providerStatuses.Any(status => status == ProviderStatus.Stale))
+ {
+ return ProviderStatus.Stale;
+ }
+
+ return providerStatuses.All(status => status == ProviderStatus.Ready) ? ProviderStatus.Ready :
+ // Default to NotReady if we have mixed statuses not covered above
+ ProviderStatus.NotReady;
+ }
+
+ 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();
+ this._providerStatus = ProviderStatus.Fatal;
+ this._eventProcessingCancellation.Dispose();
+ }
+ }
+
+ private async Task ShutdownEventProcessingAsync()
+ {
+ // Cancel event processing - protect against ObjectDisposedException during concurrent shutdown
+ try
+ {
+ this._eventProcessingCancellation.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Expected if already disposed during concurrent shutdown
+ }
+
+ // Wait for all event listening tasks to complete, ignoring cancellation exceptions
+ if (this._eventListeningTasks.Count != 0)
+ {
+ try
+ {
+ await Task.WhenAll(this._eventListeningTasks.Values).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when shutting down
+ }
+ }
+ }
+
+ private async Task InternalShutdownAsync(CancellationToken cancellationToken)
+ {
+ await this.ShutdownEventProcessingAsync().ConfigureAwait(false);
+ await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // We should be able to shut down the provider when it is in Ready or Fatal status.
+ if ((this.Status != ProviderStatus.Ready && this.Status != 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.Status = ProviderStatus.NotReady;
+ this._eventListeningTasks.Clear();
+ }
+ finally
+ {
+ this._shutdownSemaphore.Release();
+ }
+ }
+
+ ///
+ /// This should only be used for testing purposes.
+ ///
+ /// The status to set.
+ internal void SetStatus(ProviderStatus providerStatus)
+ {
+ this.Status = providerStatus;
+ }
+
+ [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Provider {ProviderName} is already being listened to")]
+ private partial void LogProviderAlreadyBeingListenedTo(string providerName);
+}
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/OpenFeature.Providers.MultiProvider.csproj b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj
new file mode 100644
index 000000000..000f223b5
--- /dev/null
+++ b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj
@@ -0,0 +1,18 @@
+๏ปฟ
+
+
+ net8.0;net9.0;netstandard2.0;net462
+ OpenFeature.Providers.MultiProvider
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs
new file mode 100644
index 000000000..160fc9e00
--- /dev/null
+++ b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs
@@ -0,0 +1,180 @@
+using System.Collections.Immutable;
+using Microsoft.Extensions.Logging;
+using OpenFeature.Constant;
+using OpenFeature.Error;
+using OpenFeature.Extension;
+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,
+ ILogger logger,
+ CancellationToken cancellationToken = default)
+ {
+ var key = providerContext.FlagKey;
+
+ try
+ {
+ // Execute provider hooks for this specific provider
+ var providerHooks = provider.GetProviderHooks();
+ EvaluationContext? contextForThisProvider = evaluationContext;
+
+ if (providerHooks.Count > 0)
+ {
+ // Execute hooks for this provider with context isolation
+ var (modifiedContext, hookResult) = await ExecuteBeforeEvaluationHooksAsync(
+ provider,
+ providerHooks,
+ key,
+ defaultValue,
+ evaluationContext,
+ logger,
+ cancellationToken).ConfigureAwait(false);
+
+ if (hookResult != null)
+ {
+ return hookResult;
+ }
+
+ contextForThisProvider = modifiedContext ?? evaluationContext;
+ }
+
+ // Evaluate the flag with the (possibly modified) context
+ var result = defaultValue switch
+ {
+ bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, contextForThisProvider, cancellationToken).ConfigureAwait(false),
+ _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}")
+ };
+
+ // Execute after/finally hooks for this provider if we have them
+ if (providerHooks.Count > 0)
+ {
+ await ExecuteAfterEvaluationHooksAsync(provider, providerHooks, key, defaultValue, contextForThisProvider, result, logger, cancellationToken).ConfigureAwait(false);
+ }
+
+ 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);
+ }
+ }
+
+ private static async Task<(EvaluationContext?, ProviderResolutionResult?)> ExecuteBeforeEvaluationHooksAsync(
+ FeatureProvider provider,
+ IImmutableList hooks,
+ string key,
+ T defaultValue,
+ EvaluationContext? evaluationContext,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var sharedHookContext = new SharedHookContext(
+ key,
+ defaultValue,
+ GetFlagValueType(),
+ new ClientMetadata(MultiProviderConstants.ProviderName, null),
+ provider.GetMetadata()
+ );
+
+ var initialContext = evaluationContext ?? EvaluationContext.Empty;
+ var hookRunner = new HookRunner([.. hooks], initialContext, sharedHookContext, logger);
+
+ // Execute before hooks for this provider
+ var modifiedContext = await hookRunner.TriggerBeforeHooksAsync(null, cancellationToken).ConfigureAwait(false);
+ return (modifiedContext, null);
+ }
+ catch (Exception hookEx)
+ {
+ // If before hooks fail, return error result
+ var errorResult = new ResolutionDetails(
+ key,
+ defaultValue,
+ ErrorType.General,
+ Reason.Error,
+ errorMessage: $"Provider hook execution failed: {hookEx.Message}");
+
+ var result = new ProviderResolutionResult(provider, provider.GetMetadata()?.Name ?? "unknown", errorResult, hookEx);
+ return (null, result);
+ }
+ }
+
+ private static async Task ExecuteAfterEvaluationHooksAsync(
+ FeatureProvider provider,
+ IImmutableList hooks,
+ string key,
+ T defaultValue,
+ EvaluationContext? evaluationContext,
+ ResolutionDetails result,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var sharedHookContext = new SharedHookContext(
+ key,
+ defaultValue,
+ GetFlagValueType(),
+ new ClientMetadata(MultiProviderConstants.ProviderName, null),
+ provider.GetMetadata()
+ );
+
+ var hookRunner = new HookRunner([.. hooks], evaluationContext ?? EvaluationContext.Empty, sharedHookContext, logger);
+
+ var evaluationDetails = result.ToFlagEvaluationDetails();
+
+ if (result.ErrorType == ErrorType.None)
+ {
+ await hookRunner.TriggerAfterHooksAsync(evaluationDetails, null, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ var exception = new FeatureProviderException(result.ErrorType, result.ErrorMessage);
+ await hookRunner.TriggerErrorHooksAsync(exception, null, cancellationToken).ConfigureAwait(false);
+ }
+
+ await hookRunner.TriggerFinallyHooksAsync(evaluationDetails, null, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception hookEx)
+ {
+ // Log hook execution errors but don't fail the evaluation
+ logger.LogWarning(hookEx, "Provider after/finally hook execution failed for provider {ProviderName}", provider.GetMetadata()?.Name ?? "unknown");
+ }
+ }
+
+ internal static FlagValueType GetFlagValueType()
+ {
+ return typeof(T) switch
+ {
+ _ when typeof(T) == typeof(bool) => FlagValueType.Boolean,
+ _ when typeof(T) == typeof(string) => FlagValueType.String,
+ _ when typeof(T) == typeof(int) => FlagValueType.Number,
+ _ when typeof(T) == typeof(double) => FlagValueType.Number,
+ _ when typeof(T) == typeof(Value) => FlagValueType.Object,
+ _ => FlagValueType.Object // Default fallback
+ };
+ }
+}
diff --git a/src/OpenFeature.Providers.MultiProvider/README.md b/src/OpenFeature.Providers.MultiProvider/README.md
new file mode 100644
index 000000000..8b12807c0
--- /dev/null
+++ b/src/OpenFeature.Providers.MultiProvider/README.md
@@ -0,0 +1,255 @@
+# OpenFeature .NET MultiProvider
+
+[](https://www.nuget.org/packages/OpenFeature.Providers.MultiProvider)
+
+The MultiProvider is 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. This enables scenarios where different feature flags may be served by different sources or providers within the same application.
+
+## Overview
+
+The MultiProvider acts as a composite provider that can delegate flag resolution to different underlying providers based on configuration or routing rules. It supports various evaluation strategies to determine how multiple providers should be evaluated and how their results should be combined.
+
+For more information about the MultiProvider specification, see the [OpenFeature Multi Provider specification](https://openfeature.dev/specification/appendix-a/#multi-provider).
+
+## Installation
+
+```shell
+dotnet add package OpenFeature.Providers.MultiProvider
+```
+
+## Usage
+
+### Basic Setup
+
+```csharp
+using OpenFeature;
+using OpenFeature.Providers.MultiProvider;
+
+// Create your individual providers
+var primaryProvider = new YourPrimaryProvider();
+var fallbackProvider = new YourFallbackProvider();
+
+// Create provider entries
+var providerEntries = new[]
+{
+ new ProviderEntry(primaryProvider, "primary"),
+ new ProviderEntry(fallbackProvider, "fallback")
+};
+
+// Create and set the MultiProvider
+var multiProvider = new MultiProvider(providerEntries);
+await Api.Instance.SetProviderAsync(multiProvider);
+
+// Use the client as normal
+var client = Api.Instance.GetClient();
+var result = await client.GetBooleanValueAsync("my-flag", false);
+```
+
+### Evaluation Strategies
+
+The MultiProvider supports several evaluation strategies to determine how providers are evaluated:
+
+#### 1. FirstMatchStrategy (Default)
+
+Returns the first result that does not indicate "flag not found". Providers are evaluated sequentially in the order they were configured.
+
+```csharp
+using OpenFeature.Providers.MultiProvider.Strategies;
+
+var strategy = new FirstMatchStrategy();
+var multiProvider = new MultiProvider(providerEntries, strategy);
+```
+
+#### 2. FirstSuccessfulStrategy
+
+Returns the first result that does not result in an error. If any provider returns an error, it's ignored as long as there is a successful result.
+
+```csharp
+using OpenFeature.Providers.MultiProvider.Strategies;
+
+var strategy = new FirstSuccessfulStrategy();
+var multiProvider = new MultiProvider(providerEntries, strategy);
+```
+
+#### 3. ComparisonStrategy
+
+Evaluates all providers and compares their results. Useful for testing or validation scenarios where you want to ensure providers return consistent values.
+
+```csharp
+using OpenFeature.Providers.MultiProvider.Strategies;
+
+var strategy = new ComparisonStrategy();
+var multiProvider = new MultiProvider(providerEntries, strategy);
+```
+
+### Advanced Configuration
+
+#### Named Providers
+
+You can assign names to providers for better identification and debugging:
+
+```csharp
+var providerEntries = new[]
+{
+ new ProviderEntry(new ProviderA(), "provider-a"),
+ new ProviderEntry(new ProviderB(), "provider-b"),
+ new ProviderEntry(new ProviderC(), "provider-c")
+};
+```
+
+#### Custom Evaluation Context
+
+The MultiProvider respects evaluation context and passes it to underlying providers:
+
+```csharp
+var context = EvaluationContext.Builder()
+ .Set("userId", "user123")
+ .Set("environment", "production")
+ .Build();
+
+var result = await client.GetBooleanValueAsync("feature-flag", false, context);
+```
+
+## Use Cases
+
+### Primary/Fallback Configuration
+
+Use multiple providers with fallback capabilities:
+
+```csharp
+var providerEntries = new[]
+{
+ new ProviderEntry(new RemoteProvider(), "remote"),
+ new ProviderEntry(new LocalCacheProvider(), "cache"),
+ new ProviderEntry(new StaticProvider(), "static")
+};
+
+var multiProvider = new MultiProvider(providerEntries, new FirstSuccessfulStrategy());
+```
+
+### A/B Testing Provider Comparison
+
+Compare results from different providers for testing purposes:
+
+```csharp
+var providerEntries = new[]
+{
+ new ProviderEntry(new ProviderA(), "provider-a"),
+ new ProviderEntry(new ProviderB(), "provider-b")
+};
+
+var multiProvider = new MultiProvider(providerEntries, new ComparisonStrategy());
+```
+
+### Migration Scenarios
+
+Gradually migrate from one provider to another:
+
+```csharp
+var providerEntries = new[]
+{
+ new ProviderEntry(new NewProvider(), "new-provider"),
+ new ProviderEntry(new LegacyProvider(), "legacy-provider")
+};
+
+var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
+```
+
+## Error Handling
+
+The MultiProvider handles errors from underlying providers according to the chosen evaluation strategy:
+
+- **FirstMatchStrategy**: Throws errors immediately when encountered
+- **FirstSuccessfulStrategy**: Ignores errors if there's a successful result, throws all errors if all providers fail
+- **ComparisonStrategy**: Collects and reports all errors for analysis
+
+## Thread Safety
+
+The MultiProvider is thread-safe and can be used concurrently across multiple threads. It properly handles initialization and shutdown of underlying providers.
+
+## Lifecycle Management
+
+The MultiProvider manages the lifecycle of all registered providers:
+
+```csharp
+// Initialize all providers
+await multiProvider.InitializeAsync(context);
+
+// Shutdown all providers
+await multiProvider.ShutdownAsync();
+
+// Dispose (implements IAsyncDisposable)
+await multiProvider.DisposeAsync();
+```
+
+## Events
+
+The MultiProvider supports OpenFeature events and provides specification-compliant event handling. It follows the [OpenFeature Multi-Provider specification](https://openfeature.dev/specification/appendix-a#status-and-event-handling) for event handling behavior.
+
+### Event Handling Example
+
+```csharp
+using OpenFeature;
+using OpenFeature.Providers.MultiProvider;
+
+// Create the MultiProvider with multiple providers
+var providerEntries = new[]
+{
+ new ProviderEntry(new ProviderA(), "provider-a"),
+ new ProviderEntry(new ProviderB(), "provider-b")
+};
+var multiProvider = new MultiProvider(providerEntries);
+
+// Subscribe to MultiProvider events
+Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) =>
+{
+ Console.WriteLine($"MultiProvider is ready: {eventDetails?.ProviderName}");
+});
+
+Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, (eventDetails) =>
+{
+ Console.WriteLine($"MultiProvider became stale: {eventDetails?.Message}");
+});
+
+Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, (eventDetails) =>
+{
+ Console.WriteLine($"Configuration changed - Flags: {string.Join(", ", eventDetails?.FlagsChanged ?? [])}");
+});
+
+Api.Instance.AddHandler(ProviderEventTypes.ProviderError, (eventDetails) =>
+{
+ Console.WriteLine($"MultiProvider error: {eventDetails?.Message}");
+});
+
+// Set the provider - this will initialize all underlying providers
+// and emit PROVIDER_READY when all are successfully initialized
+await Api.Instance.SetProviderAsync(multiProvider);
+
+// Later, if an underlying provider becomes stale and changes MultiProvider status:
+// Only then will a PROVIDER_STALE event be emitted from MultiProvider
+```
+
+### Event Lifecycle
+
+1. **During Initialization**:
+
+ - MultiProvider emits `PROVIDER_READY` when all underlying providers initialize successfully
+ - MultiProvider emits `PROVIDER_ERROR` if any providers fail to initialize (causing aggregate status to become ERROR/FATAL)
+
+2. **Runtime Status Changes**:
+
+ - Status-changing events from underlying providers are captured internally
+ - MultiProvider only emits events when its aggregate status changes due to these internal events
+ - Example: If MultiProvider is READY and one provider becomes STALE, MultiProvider emits `PROVIDER_STALE`
+
+3. **Configuration Changes**:
+ - `PROVIDER_CONFIGURATION_CHANGED` events from underlying providers are always re-emitted
+
+## Requirements
+
+- .NET 8+
+- .NET Framework 4.6.2+
+- .NET Standard 2.0+
+
+## Contributing
+
+See the [OpenFeature .NET SDK contributing guide](../../CONTRIBUTING.md) for details on how to contribute to this project.
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/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs
index cea661398..e4a9826c5 100644
--- a/src/OpenFeature/Api.cs
+++ b/src/OpenFeature/Api.cs
@@ -32,7 +32,7 @@ public sealed class Api : IEventBus
// not to mark type as beforeFieldInit
// IE Lazy way of ensuring this is thread safe without using locks
static Api() { }
- private Api() { }
+ internal Api() { }
///
/// Sets the default feature provider. In order to wait for the provider to be set, and initialization to complete,
@@ -85,7 +85,7 @@ public FeatureProvider GetProvider()
/// Gets the feature provider with given domain
///
/// An identifier which logically binds clients with providers
- /// A provider associated with the given domain, if domain is empty or doesn't
+ /// A provider associated with the given domain, if domain is empty, null, whitespace or doesn't
/// have a corresponding provider the default provider will be returned
public FeatureProvider GetProvider(string domain)
{
@@ -114,14 +114,14 @@ public FeatureProvider GetProvider(string domain)
///
/// Create a new instance of using the current provider
///
- /// Name of client
+ /// Name of client, if the is not provided a default name will be used
/// Version of client
/// Logger instance used by client
/// Context given to this client
///
public FeatureClient GetClient(string? name = null, string? version = null, ILogger? logger = null,
EvaluationContext? context = null) =>
- new FeatureClient(() => this._repository.GetProvider(name), name, version, logger, context);
+ new FeatureClient(this, () => this._repository.GetProvider(name), name, version, logger, context);
///
/// Appends list of hooks to global hooks list
@@ -360,4 +360,12 @@ internal static void ResetApi()
{
Instance = new Api();
}
+
+ ///
+ /// This method should only be used in the Dependency Injection setup. It will set the singleton instance of the API using the provided instance.
+ ///
+ internal static void SetInstance(Api api)
+ {
+ Instance = api;
+ }
}
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/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs
index 73c391250..be84ca3f0 100644
--- a/src/OpenFeature/Extension/EnumExtensions.cs
+++ b/src/OpenFeature/Extension/EnumExtensions.cs
@@ -1,13 +1,32 @@
-using System.ComponentModel;
+using OpenFeature.Constant;
namespace OpenFeature.Extension;
internal static class EnumExtensions
{
+ ///
+ /// Gets the description of an enum value without using reflection.
+ /// This is AOT-compatible and only supports specific known enum types.
+ ///
+ /// The enum value to get the description for
+ /// The description string or the enum value as string if no description is available
public static string GetDescription(this Enum value)
{
- var field = value.GetType().GetField(value.ToString());
- var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute;
- return attribute?.Description ?? value.ToString();
+ return value switch
+ {
+ // ErrorType descriptions
+ ErrorType.None => "NONE",
+ ErrorType.ProviderNotReady => "PROVIDER_NOT_READY",
+ ErrorType.FlagNotFound => "FLAG_NOT_FOUND",
+ ErrorType.ParseError => "PARSE_ERROR",
+ ErrorType.TypeMismatch => "TYPE_MISMATCH",
+ ErrorType.General => "GENERAL",
+ ErrorType.InvalidContext => "INVALID_CONTEXT",
+ ErrorType.TargetingKeyMissing => "TARGETING_KEY_MISSING",
+ ErrorType.ProviderFatal => "PROVIDER_FATAL",
+
+ // Fallback for any other enum types
+ _ => value.ToString()
+ };
}
}
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..6852b47c6
--- /dev/null
+++ b/src/OpenFeature/Hooks/MetricsHook.cs
@@ -0,0 +1,135 @@
+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);
+
+ 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.
+ ///
+ /// 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;
+ }
+
+ ///
+ 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.AddCustomDimensions(ref tagList);
+
+ 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.AddCustomDimensions(ref tagList);
+ this.AddFlagMetadataDimensions(details.FlagMetadata, ref tagList);
+
+ 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.AddCustomDimensions(ref tagList);
+
+ 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.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