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)
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)
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
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.
| Phase | What is recorded |
|---|---|
| Enter | One usage attempt (usedCount) for the variant (default label enabled) |
| Success | Whole milliseconds elapsed on clean completion added to success duration |
| Failure | One error plus elapsed ms added to error duration; the failure still propagates to your caller |
| Cancellation | Attempt 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:
| Metric | Formula | What it tells you |
|---|---|---|
| Eligibility Rate | Enabled Checks / Total Checks | % of users who qualify for the feature |
| Discovery Rate | Views / Enabled Checks | % of eligible users who found/visited the feature |
| Engagement Rate | Usage / Views | % of visitors who took action |
| Overall Adoption | Usage / Enabled Checks | % of eligible users who engaged |
| Scope error rate | Usage errors ÷ Usage scope attempts | Reliability of scoped feature work |
| Mean success duration | Success duration sum ÷ Successful scope completions | Typical 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() { }
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 Type | Check | View | Usage |
|---|---|---|---|
| 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
- View your feature metrics in the Dashboard
- Learn about Experiments to A/B test features
- Explore Metrics & Events for business metrics