Skip to main content

Feature Usage Tracking

Understanding how users interact with your features is essential for making data-driven decisions. Toggly tracks three distinct types of feature interactions: Checks, Views, and Usage. Each serves a different purpose in your analytics funnel.

The Feature Interaction Funnel​

┌─────────────────────────────────────────────────────────────────┐
│ CHECKS │
│ "How many users are ELIGIBLE for the feature?" │
│ ✅ Automatic - happens when IsEnabledAsync() is called │
├─────────────────────────────────────────────────────────────────┤
│ VIEWS │
│ "How many users VISITED the feature?" │
│ 👁️ Use [FeatureView] on pages that ARE the feature │
├─────────────────────────────────────────────────────────────────┤
│ USAGE │
│ "How many users ENGAGED with the feature?" │
│ 🖱️ Use [FeatureUsage] on actions within the feature │
└─────────────────────────────────────────────────────────────────┘

Understanding Each Metric​

Feature Checks (Evaluations)​

A check occurs every time your application evaluates whether a feature is enabled. This is automatically tracked by Toggly when you use the feature management SDK.

What it measures:

  • How often the feature decision is being made
  • The ratio of enabled vs disabled evaluations
  • Whether the feature code path is being executed

When it happens:

// Every call to IsEnabledAsync triggers a "check"
if (await featureManager.IsEnabledAsync("new-checkout"))
{
// Check recorded: enabled
}
else
{
// Check recorded: disabled
}

Use cases:

  • Detecting stale feature flags (no checks = code removed)
  • Understanding feature reach (how often the decision point is hit)
  • Debugging targeting rules (unexpected enabled/disabled ratios)
Automatic Tracking

Checks are recorded automatically. You don't need to call any additional methods.


Feature Views (Impressions)​

A view represents when a feature's UI component is actually rendered and displayed to the user. This is manually tracked because only you know when your feature is truly "seen."

What it measures:

  • How many users actually saw the feature
  • Impression counts for A/B test analysis
  • Visual reach of the feature

Views are count-only. They do not record duration, errors, or latency.

When to use it:

// User navigates to a page that IS the feature
// Landing on this page = viewing the feature
[FeatureView("new-dashboard")]
public IActionResult Dashboard()
{
return View();
}

The difference from checks:

  • A check happens at the decision point (is user eligible?)
  • A view happens when the user arrives at the feature

Consider this scenario:

// Home page - CHECK happens here (user qualifies for the feature)
public async Task<IActionResult> Index()
{
if (await featureManager.IsEnabledAsync("new-dashboard"))
{
// User qualifies, but they haven't SEEN the dashboard yet!
ViewBag.ShowDashboardLink = true;
}
return View();
}

// Dashboard page - VIEW happens when they actually visit
[FeatureView("new-dashboard")]
public IActionResult Dashboard()
{
// NOW we know the user actually visited the feature
return View();
}

Use cases:

  • Measuring how many users actually visited the feature page
  • Calculating "view → usage" conversion rates
  • Understanding feature discovery (how many eligible users find it)
ASP.NET Core Attribute

Use [FeatureView] on controller actions that serve feature pages. It gates AND tracks - checking if the feature is enabled and recording the view when the user lands on the page:

// User landing on this page = viewing the feature
[FeatureView("new-dashboard")]
public IActionResult Dashboard()
{
return View();
}

If the feature is disabled, the action returns 404 (or your custom IDisabledFeaturesHandler behavior).


Feature Usage (Interactions)​

Usage tracks when a user actually interacts with or engages with a feature. This is manually tracked when meaningful user actions occur.

What it measures:

  • Active engagement with the feature
  • Conversion from viewing to using
  • Feature adoption and stickiness

Calling RecordUsageAsync / recordUsage records a single usage count. It does not measure how long the action took or whether it succeeded. Use a usage scope when you need attempt counts plus success duration and error rate (see Usage scopes).

When to use it:

// User takes an action within the feature
// This shows they actually engaged with it, not just visited
[FeatureUsage("new-checkout")]
public async Task<IActionResult> SubmitOrder(OrderModel order)
{
// User completed an action = they USED the feature
await _orderService.ProcessAsync(order);
return RedirectToAction("Confirmation");
}

Examples of usage events:

  • Submitting a form (not just viewing it)
  • Completing a purchase
  • Saving settings
  • Sending a message
  • Any meaningful action that shows engagement

Use cases:

  • Measuring feature adoption
  • Calculating engagement rates
  • Determining if users find value in the feature
  • Making rollout decisions based on actual usage
ASP.NET Core Attribute

Use [FeatureUsage] on controller actions where users take meaningful actions. It gates AND tracks - checking if the feature is enabled and wrapping the action in a usage scope when it runs (one attempt, success or error duration, propagated failures):

// User submitting the form = using the feature
[FeatureUsage("new-checkout")]
public async Task<IActionResult> SubmitOrder(OrderModel order)
{
// Process order...
}

If the feature is disabled, the action returns 404 (or your custom IDisabledFeaturesHandler behavior).


Usage scopes​

A usage scope tracks one attempt to deliver feature value, including how long it took and whether it failed. Scopes do not evaluate the feature flag again; gate with IsEnabled or [FeatureGate] / [FeatureUsage] before entering the scope.

PhaseWhat is recorded
EnterOne usage attempt (usedCount) for the variant (default label enabled)
SuccessWhole milliseconds elapsed on clean completion added to success duration
FailureOne error plus elapsed ms added to error duration; the failure still propagates to your caller
CancellationAttempt only — no error and no duration

Failure means an uncaught exception, a returned error (in Go and Rust), or an explicit Fail() / fail() on the scope handle without throwing. If you both call Fail() and throw, the scope counts one error.

Cancellation is cooperative abandonment: .NET OperationCanceledException, JavaScript AbortError / CanceledError, Python asyncio.CancelledError, Go context.Canceled, Java InterruptedException, and similar platform signals. Rust treats every Err from scoped work as a failure unless you handle it outside the scope.

Nested scopes are independent. When an attribute lists multiple features, they share one clock and receive the same outcome.

// Manual scope (server SDK)
await using (var scope = await usageStats.BeginUsageAsync("new-checkout"))
{
await ProcessOrderAsync(order);
// Success duration recorded on dispose
}

// Or let the SDK manage the scope
await usageStats.UseAsync("new-checkout", async scope =>
{
var ok = await TryChargeAsync();
if (!ok)
scope.Fail(); // Counts as failure without throwing
});

Use bare recordUsage when you only need a conversion count (for example a button click with no timing). Use a scope for workflows where duration and reliability matter (checkout, provisioning, model calls).


Putting It All Together​

Here's a real-world example showing all three metrics. The key distinction:

  • View = User lands on a page that IS the feature
  • Usage = User takes an action within that feature
public class CheckoutController : Controller
{
private readonly IFeatureManager _featureManager;

// Step 1: CHECK - Happens automatically when routing decisions are made
// or when you call IsEnabledAsync() to decide what to show
public async Task<IActionResult> Index()
{
// Automatic check happens here - determines which checkout to show
if (await _featureManager.IsEnabledAsync("new-checkout"))
{
return RedirectToAction("NewCheckout");
}
return View("LegacyCheckout");
}

// Step 2: VIEW - User lands on the new checkout page
// Just arriving at this page = they're viewing the feature
[FeatureView("new-checkout")]
public IActionResult NewCheckout()
{
// User is now VIEWING the new checkout experience
return View();
}

// Step 3: USAGE - User submits their order (takes action)
// This is actual engagement with the feature
[FeatureUsage("new-checkout")]
public async Task<IActionResult> SubmitOrder(OrderModel order)
{
// User is now USING the new checkout - they completed an action
await _orderService.ProcessAsync(order);
return RedirectToAction("Confirmation");
}
}

The mental model:

  • Check: "Is this user eligible for the feature?" (automatic)
  • View: "Did the user see/visit the feature?" (landing on a feature page)
  • Usage: "Did the user engage with the feature?" (taking an action)

Analyzing the Funnel​

With all three metrics, you can calculate:

MetricFormulaWhat it tells you
Eligibility RateEnabled Checks / Total Checks% of users who qualify for the feature
Discovery RateViews / Enabled Checks% of eligible users who found/visited the feature
Engagement RateUsage / Views% of visitors who took action
Overall AdoptionUsage / Enabled Checks% of eligible users who engaged
Scope error rateUsage errors ÷ Usage scope attemptsReliability of scoped feature work
Mean success durationSuccess duration sum ÷ Successful scope completionsTypical time to finish scoped actions

Scope error rate and mean success duration apply to usage scopes, not to bare recordUsage counts or views.


SDK Reference​

Recording Views​

// Basic view recording
await usageStats.RecordViewAsync("feature-key");

// With context (for unique user tracking)
await usageStats.RecordViewAsync("feature-key", userContext);

Recording Usage​

// Basic usage recording (count only — no duration or errors)
await usageStats.RecordUsageAsync("feature-key");

// With context (for unique user tracking)
await usageStats.RecordUsageAsync("feature-key", userContext);

Usage scopes​

// Scope with explicit disposal
await using var scope = await usageStats.BeginUsageAsync("feature-key");
await DoWorkAsync();

// With user context on the attempt
await using var scope = await usageStats.BeginUsageWithContextAsync(userContext, "feature-key");

// Convenience wrapper (handles cancellation and exceptions)
await usageStats.UseAsync("feature-key", async () => await DoWorkAsync());

// Soft failure without throwing
await usageStats.UseAsync("feature-key", async scope =>
{
if (!await ValidateAsync())
scope.Fail();
});

ASP.NET Core Attributes​

Both [FeatureView] and [FeatureUsage] attributes gate AND track - they check if features are enabled before allowing the action to execute:

// Gates on feature-key, records view if enabled, returns 404 if disabled
[FeatureView("feature-key")]
public IActionResult MyAction() { }

// Gates on feature-key, records usage if enabled, returns 404 if disabled
[FeatureUsage("feature-key")]
public IActionResult MyAction() { }

// Multiple features (all must be enabled by default)
[FeatureView("feature-a", "feature-b")]
public IActionResult MyAction() { }

// RequirementType.Any - at least one feature must be enabled
[FeatureView("feature-a", "feature-b", RequirementType = RequirementType.Any)]
public IActionResult MyAction() { }
No FeatureGate Needed

You don't need to combine [FeatureGate] with [FeatureView] or [FeatureUsage] - the tracking attributes already include the gating behavior.


Best Practices​

1. Don't Over-Track​

Not every feature needs all three metrics. Choose based on your analysis needs:

Feature TypeCheckViewUsage
Backend optimization✅❌❌
UI component✅✅Optional
Interactive feature✅✅✅

2. Be Consistent​

Define what "view" and "usage" mean for your organization:

  • View: The moment the feature is rendered/displayed
  • Usage: A meaningful interaction (not just hovering)

3. Use Context for Unique Users​

Pass user context to get accurate unique user counts:

var context = new { UserId = currentUser.Id };
await usageStats.RecordViewAsync("feature", context);
await usageStats.RecordUsageAsync("feature", context);

4. Record at the Right Time​

  • Views: Record when the UI is actually rendered, not just when data is loaded
  • Usage: Record on meaningful actions, not on every click

Next Steps​