Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Microsoft.FeatureManagement.sln
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EvaluationDataToApplication
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.FeatureManagement.Telemetry.ApplicationInsights.AspNetCore", "src\Microsoft.FeatureManagement.Telemetry.ApplicationInsights.AspNetCore\Microsoft.FeatureManagement.Telemetry.ApplicationInsights.AspNetCore.csproj", "{C647611B-A8E5-4AD7-9DBA-60DDE276644B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorServerApp", "examples\BlazorServerApp\BlazorServerApp.csproj", "{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorServerApp", "examples\BlazorServerApp\BlazorServerApp.csproj", "{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VariantServiceDemo", "examples\VariantServiceDemo\VariantServiceDemo.csproj", "{E8E17CB9-434E-4386-BF96-FA53BBFDCD6F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -85,6 +87,10 @@ Global
{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E}.Release|Any CPU.Build.0 = Release|Any CPU
{E8E17CB9-434E-4386-BF96-FA53BBFDCD6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8E17CB9-434E-4386-BF96-FA53BBFDCD6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8E17CB9-434E-4386-BF96-FA53BBFDCD6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8E17CB9-434E-4386-BF96-FA53BBFDCD6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -98,6 +104,7 @@ Global
{283D3EBB-4716-4F1D-BA51-A435F7E2AB82} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
{1502529E-47E9-4306-98C4-BF6CF7C7C275} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
{12BAB5A6-4EEB-4917-B5D9-4AFB6253008E} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
{E8E17CB9-434E-4386-BF96-FA53BBFDCD6F} = {FB5C34DF-695C-4DF9-8AED-B3EA2516EA72}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {84DA6C54-F140-4518-A1B4-E4CF42117FBD}
Expand Down
53 changes: 32 additions & 21 deletions examples/BlazorServerApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,44 @@ This app demonstrates how to use the Feature Management library in Blazor apps.
This app uses two feature flags: "BrowserEnhancement" and "Beta".

``` json
"FeatureManagement": {
"BrowserEnhancement": {
"EnabledFor": [
{
"Name": "Browser",
"Parameters": {
"AllowedBrowsers": [ "Edge" ]
}
"feature_management": {
"feature_flags": [
{
"id": "BrowserEnhancement",
"enabled": true,
"conditions": {
"client_filters": [
{
"name": "Browser",
"parameters": {
"AllowedBrowsers": [ "Edge" ]
}
}
]
}
},
"Beta": {
"EnabledFor": [
{
"Name": "Targeting",
"Parameters": {
"Audience": {
"DefaultRolloutPercentage": 50,
"Exclusion": {
"Groups": [
"Guests"
]
}
}
{
"id": "Beta",
"enabled": true,
"conditions": {
"client_filters": [
{
"name": "Targeting",
"parameters": {
"Audience": {
"DefaultRolloutPercentage": 50,
"Exclusion": {
"Groups": [
"Guests"
]
}
}
}
}
]
}
}
]
}
```

Expand All @@ -65,6 +75,7 @@ This app uses [cookie authentication](https://learn.microsoft.com/en-us/aspnet/c
Rather than `HttpContext`, the [`AuthenticationStateProvider`](https://learn.microsoft.com/en-us/aspnet/core/blazor/security/?view=aspnetcore-8.0#authenticationstateprovider-service) service is used to obtain the user authentication state information for setting targeting context. The details can be found in the [`MyTargetingContextAccessor`](./MyTargetingContextAccessor.cs).

## Service Registration

Blazor applications like this one typically pull ambient contextual data from scoped services. For example, the `UserAgentContext`, `AuthenticationStateProvider` and `ITargetingContextAccessor` are all scoped services. This pattern *breaks* if the feature management services are added as singleton, which is typical in non-blazor web apps.

In Blazor, *avoid* the following
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
@using System.Security.Claims
@using Microsoft.AspNetCore.Http

@inject IHttpContextAccessor httpContextAccessor
@{
string username = httpContextAccessor.HttpContext.Request.Cookies["username"];
}

<!DOCTYPE html>
<html lang="en">
<head>
Expand Down
6 changes: 3 additions & 3 deletions examples/EvaluationDataToApplicationInsights/Program.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.FeatureManagement.Telemetry;
using Microsoft.FeatureManagement;
using EvaluationDataToApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.FeatureManagement;
using Microsoft.FeatureManagement.Telemetry;
using Microsoft.FeatureManagement.Telemetry.ApplicationInsights.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
Expand Down Expand Up @@ -56,7 +56,7 @@
app.MapRazorPages();

//
// Adds Targeting Id to HttpContext
// Add Targeting Id to HttpContext
app.UseMiddleware<TargetingHttpContextMiddleware>();

app.Run();
4 changes: 4 additions & 0 deletions examples/EvaluationDataToApplicationInsights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ These logs show what would be emitted to a connected Application Insights resour
To flow these events to Application Insights, [setup a new Application Insights resource in Azure](https://learn.microsoft.com/en-us/azure/azure-monitor/app/create-workspace-resource). Once setup, from `Overview` copy the `Connection String` and place it in `appsettings.json` at ApplicationInsights > ConnectionString. After restarting the app, events should now flow to Application Insights. This [document](https://learn.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core?tabs=netcorenew%2Cnetcore6#enable-application-insights-server-side-telemetry-no-visual-studio) provides more details on connecting a .NET application to Application Insights.

## About the App

This app uses [Application Insights for ASP.NET Core](https://learn.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core?tabs=netcorenew%2Cnetcore6). This means there is an App Insights SDK in the C# code and a separate App Insights SDK the Javascript. Lets cover what they're doing:

### Javascript App Insights SDK

See [Enable cliend-side telemetry for web applications](https://learn.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core?tabs=netcorenew%2Cnetcore6#enable-client-side-telemetry-for-web-applications)

For ASP.NET, this is added to _ViewImports.cshtml
Expand All @@ -47,11 +49,13 @@ These cookies are used to correlate telemetry from the browser with telemetry fr
*The Javascript SDK is not required, but is useful for collecting browser telemetry and generating these cookies out of the box.*

### Targeting Id

In order to connect evaluation events with other metrics from the user, a targeting id needs to be emitted. This can be done multiple ways, but the recommended way is to define a telemetry initializer. This initializer allows the app to modify all telemetry going to Application Insights before it's sent.

This example uses the provided `TargetingHttpContextMiddleware` and `TargetingTelemetryInitializer`. The middleware adds `TargetingId` (using the targeting context accessor) to the HTTP Context as a request comes in. The initializer checks for the `TargetingId` on the HTTP Context, and if it exists, adds `TargetingId` to all outgoing Application Insights Telemetry.

## Sample App Usage

Sample steps to try out the app:

1. Run the app. When the app is first started a User Id and Session Id will be generated. The username cookie will be set to a random integer, and the ai_user and ai_session cookies will be expired.
Expand Down
16 changes: 16 additions & 0 deletions examples/VariantServiceDemo/DefaultCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.FeatureManagement;

namespace VariantServiceDemo
{
[VariantServiceAlias("DefaultCalculator")]
public class DefaultCalculator : ICalculator
{
public Task<double> AddAsync(double a, double b)
{
return Task.FromResult(a + b);
}
}
}
53 changes: 53 additions & 0 deletions examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.FeatureManagement.FeatureFilters;

namespace VariantServiceDemo
{
/// <summary>
/// Provides an implementation of <see cref="ITargetingContextAccessor"/> that creates a targeting context using info from the current HTTP request.
/// </summary>
public class HttpContextTargetingContextAccessor : ITargetingContextAccessor
{
private const string TargetingContextLookup = "HttpContextTargetingContextAccessor.TargetingContext";
private readonly IHttpContextAccessor _httpContextAccessor;

public HttpContextTargetingContextAccessor(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
}

public ValueTask<TargetingContext> GetContextAsync()
{
HttpContext httpContext = _httpContextAccessor.HttpContext;

//
// Try cache lookup
if (httpContext.Items.TryGetValue(TargetingContextLookup, out object value))
{
return new ValueTask<TargetingContext>((TargetingContext)value);
}

//
// Grab username from cookie
string username = httpContext.Request.Cookies["username"];

var groups = new List<string>();

//
// Build targeting context based on user info
var targetingContext = new TargetingContext
{
UserId = username,
Groups = groups
};

//
// Cache for subsequent lookup
httpContext.Items[TargetingContextLookup] = targetingContext;

return new ValueTask<TargetingContext>(targetingContext);
}
}
}
10 changes: 10 additions & 0 deletions examples/VariantServiceDemo/ICalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
namespace VariantServiceDemo
{
public interface ICalculator
{
public Task<double> AddAsync(double a, double b);
}
}
26 changes: 26 additions & 0 deletions examples/VariantServiceDemo/Pages/Error.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}

<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>

@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}

<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
28 changes: 28 additions & 0 deletions examples/VariantServiceDemo/Pages/Error.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Diagnostics;

namespace VariantServiceDemo.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }

public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);

private readonly ILogger<ErrorModel> _logger;

public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}

public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}

}
47 changes: 47 additions & 0 deletions examples/VariantServiceDemo/Pages/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@page
@model IndexModel
@inject IHttpContextAccessor httpContextAccessor
@{
ViewData["Title"] = "Home page";
}

<div class="text-center">
<h2> Current user @Model.Username </h2>
</div>

<calculator>
<input id="inputA" type="number" value="0"> +
<input id="inputB" type="number" value="0"> =
<span id="result">0</span>
<div>
<button id="calculateButton" onclick="calculate()">Calculate</button>
</div>
</calculator>

@section Scripts {
<script>
function calculate() {
var a = document.getElementById('inputA').value;
var b = document.getElementById('inputB').value;

var calculateButton = document.getElementById('calculateButton');
calculateButton.textContent = 'Calculating...';
calculateButton.disabled = true;

fetch(`/Index?handler=Calculate&a=${a}&b=${b}`)
.then(response => {
return response.json()
})
.then(data => {
document.getElementById('result').innerText = data;
})
.catch(error => {
console.error('Error:', error)
})
.finally(() => {
calculateButton.textContent = 'Calculate';
calculateButton.disabled = false;
});
}
</script>
}
40 changes: 40 additions & 0 deletions examples/VariantServiceDemo/Pages/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.FeatureManagement;

namespace VariantServiceDemo.Pages
{
public class IndexModel : PageModel
{
private readonly IVariantServiceProvider<ICalculator> _calculatorProvider;

public string Username { get; set; }

public IndexModel(IVariantServiceProvider<ICalculator> calculatorProvider)
{
_calculatorProvider = calculatorProvider ?? throw new ArgumentNullException(nameof(calculatorProvider));
}

public IActionResult OnGet()
{
//
// generate a new visitor
string visitor = Random.Shared.Next().ToString();

Response.Cookies.Append("username", visitor);

Username = visitor;

return Page();
}

public async Task<JsonResult> OnGetCalculate(double a, double b)
{
ICalculator calculator = await _calculatorProvider.GetServiceAsync(HttpContext.RequestAborted);

double result = await calculator.AddAsync(a, b);

return new JsonResult(result);
}
}
}
Loading