Understand your traffic. Measure your product. Own your data.
Web analytics, custom events, and telemetry inside your .NET application.
Quick start · Documentation · Dashboard · Benchmarks · Example app
ZoneTree.Analytics is an embedded analytics engine built on ZoneTree. Record events, keep the dataset on your infrastructure, and query it directly from C#. The core library runs in your process and requires no external database or separate analytics service.
Use it for a website's first page view, a product's conversion funnel, or a worker's processing times. Add the optional ASP.NET Core integration for request tracking, browser collection, and a reporting API, or explore the data with the included dashboard.
| Use case | What you can measure |
|---|---|
| Website analytics | Visitors, page views, sessions, bounce rate, popular pages, sources, campaigns, browsers, and devices |
| Product analytics | Signups, purchases, revenue, behavioral segments, and conversion funnels |
| Service telemetry | Event counts, durations, percentiles, histograms, error rates, and business metrics |
| Multi-site reporting | Site-scoped events and reports for multiple websites, applications, or tenants in one engine |
- One query model. Combine metrics, filters, dimensions, formulas, time buckets, and period comparisons through a typed C# API.
- Pre-aggregation for recurring reports. Read matching rollups and optional indexes; fall back to retained event history for queries that need it. Plans expose coverage and warnings.
- Privacy-conscious defaults. Cookie-free collection, daily anonymous identity rotation, and no stored raw IP addresses or user-agent strings.
- Application-owned storage. Background ingestion, configurable retention, and persistent ZoneTree stores. The core library's only direct package dependency is ZoneTree.
- Room to customize. Plug in event filters, enrichers, bot detectors, a user-agent parser, or a geolocation provider. Bot traffic can be excluded or reported separately.
The library targets .NET 8, 9, and 10. In a .NET console project, install:
dotnet add package ZoneTree.AnalyticsReplace Program.cs with this complete example:
using ZoneTree.Analytics;
using ZoneTree.Analytics.Web;
await using var analytics = AnalyticsEngine.Open("data/analytics");
// Track a page view and a business event for the same identified visitor.
var pageView = AnalyticsEvent.PageView("https://example.com/pricing");
pageView.VisitorId = "customer-42";
await analytics.TrackAsync(pageView);
await analytics.TrackAsync(new AnalyticsEvent("purchase")
{
VisitorId = "customer-42",
Dimensions = { ["plan"] = "pro" },
Values = { ["revenue"] = 49.0 },
});
// Make the events visible before reading them in this short-lived example.
await analytics.FlushAsync();
var traffic = await analytics.Web.GetSummaryAsync(new WebReportRequest
{
Range = DateRange.LastDays(30),
});
var sales = await analytics.QueryAsync(new AnalyticsQuery
{
Event = "purchase",
Range = DateRange.LastDays(30),
Metrics = { Metric.Events, Metric.Sum("revenue") },
});
Console.WriteLine($"Page views: {traffic.Summary.PageViews}");
Console.WriteLine($"Revenue: {sales.Totals[Metric.Sum("revenue")]:F2}");Run with dotnet run. On an empty dataset, this records one page view and revenue of 49.00, formatted using your current culture. The data remains in data/analytics between runs.
Keep one engine per data directory open for the application's lifetime. Events are processed in the background, with an aggregate flush interval of two seconds by default. Normal reporting does not need an explicit flush; this example uses one so its immediate read includes the events it just tracked. Await disposal at shutdown to drain the queue and flush remaining work.
Install the integration package in an ASP.NET Core project:
dotnet add package ZoneTree.Analytics.AspNetCoreThis minimal Program.cs registers the engine and tracks successful HTML page responses:
using ZoneTree.Analytics.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddZoneTreeAnalytics(
options => options.DataDirectory = "data/analytics");
var app = builder.Build();
app.UseZoneTreeAnalytics();
app.MapGet("/", () => Results.Content(
"<!doctype html><html><body><h1>Hello analytics</h1></body></html>",
"text/html"));
await app.RunAsync();Dependency injection owns the shared engine and disposes it at host shutdown. Inject IAnalyticsTracker to record custom actions, or AnalyticsEngine to query reports.
For browser collection, map app.MapZoneTreeAnalytics() and load /_analytics/script.js on your pages. Choose either server middleware or browser tracking for each page view to avoid double counting.
To expose reports, map app.MapZoneTreeAnalyticsApi(). In a host with authentication and authorization configured, protect the route group with app.MapZoneTreeAnalyticsApi().RequireAuthorization(). Multi-site applications should also configure AuthorizeSite for site-level access.
See the ASP.NET Core guide for collection choices, proxy configuration, and reporting access, or run the minimal example host from this repository:
dotnet run --project examples/MinimalHostExplore traffic, acquisition, audience, events, funnels, and bot activity. Use the query explorer for custom questions and export reports from the interface. The dashboard includes a .NET server and a static UI built with PureStack and Regor.
From the repository root, install the workspace dependencies:
yarn installThen, from the repository root, start the server with sample data:
yarn dashboard --Analytics:SeedDemo=trueOpen http://127.0.0.1:5088/. Development mode starts the UI automatically. Demo seeding populates an empty primary dataset; omit the flag to use your own data. The core library does not require Node.js or Yarn.
Read the dashboard guide for prerequisites, static release builds, and deployment settings.
The general query API supports totals, grouped reports, time series, and formulas. After tracking purchases with a plan dimension and a revenue value:
var revenue = Metric.Sum("revenue");
var averageOrder = revenue / Metric.Events;
var report = await analytics.QueryAsync(new AnalyticsQuery
{
Event = "purchase",
Range = DateRange.ThisMonth,
Interval = TimeInterval.Day,
GroupBy = { "plan" },
Metrics = { Metric.Events, revenue, averageOrder },
Compare = ComparisonPeriod.PreviousPeriod,
});
foreach (var row in report.Rows)
Console.WriteLine($"{row.Bucket:yyyy-MM-dd} | {row["plan"]}: {row[revenue]:F2} revenue");
Console.WriteLine($"Average order: {report.Totals[averageOrder]:F2}");Totals evaluate formulas over the range's totals. Here, the overall average order value is total revenue divided by the number of purchases.
| Question | Tools |
|---|---|
| Where do visitors come from? | Web breakdowns by source, channel, campaign, and referrer |
| Which plans generate revenue? | Custom dimensions, numeric aggregates, and metric formulas |
| What did purchasers view? | Visitor or session segments based on recorded behavior |
| Where do visitors drop off? | Ordered funnels with a configurable conversion window |
| How slow are background jobs? | Percentiles, histograms, moving averages, and time buckets |
Explore querying, segments and funnels, and the query reference for supported combinations and metric semantics.
The repository includes a documented benchmark over 5 million generated events spanning 365 days. It measures annual reports with previous-period comparisons enabled.
| Workload | Median | p95 |
|---|---|---|
| Complete overview: six reports, up to three requests in flight | 33.3 ms | 35.5 ms |
| Country breakdown filtered to GitHub traffic | 5.7 ms | 6.5 ms |
Measured over 30 warm loads per scope on an Intel Core Ultra 7 265KF with 64 GiB RAM, Windows x64, and a .NET 10.0.6 Release host. Times include loopback HTTP response transfer and JSON parsing; chart rendering is excluded. The dataset has low group cardinality, and filtered reports use configured covering rollup indexes. These figures describe that workload, rather than a latency guarantee.
Matching rollups answer recurring reports from aggregates. Segments, funnels, and unsupported aggregate combinations can require event scans. The benchmark report includes configuration, query plans, individual timings, and methodology; the benchmarking guide explains how to measure your workload.
The default identity strategy recognizes anonymous visitors within a UTC day using a rotating hash. Explicit visitor IDs are keyed hashes and remain stable across days. Raw IP addresses and user-agent strings are used in memory for identity and enrichment, then discarded. Page query strings are excluded by default.
Custom dimensions retain the values your application supplies, so choose what to collect. Configure dimension allow and deny lists, geography precision, identity strategy, and retention to fit your use case. Geography enrichment requires a geolocation provider.
Visitor erasure removes an explicit visitor's retained raw events, dimension-index copies, and active session state. Existing aggregate rollups are not recomputed. Read privacy and identity and retention and erasure for the full behavior.
| Concern | What to know |
|---|---|
| Storage ownership | One engine exclusively owns a data directory, including across processes. Keep it on persistent storage. Replicas with separate directories have separate datasets. |
| Ingestion pressure | The queue is bounded. Track drops events on a full queue by default; TrackAsync waits asynchronously for space. Both return whether the event was accepted. Monitor dropped-event statistics and configure request tracking's queue behavior as needed. |
| Freshness and durability | Aggregates are buffered until a successful flush. A crash can lose unflushed aggregates; persistent records follow ZoneTree's write-ahead log guarantees. Await clean shutdown. |
| History | Raw events default to 90 days; hourly rollups to 400 days; daily rollups are kept indefinitely. Hour and Day are the default enabled rollup tiers. |
| Coverage and counts | Inspect query plans and warnings when history or aggregates cannot cover a request. At default precision, unique counts are exact up to 4,096 identities per row, then have about 0.8% standard error. |
Use analytics.GetStatistics() and host logging to monitor the engine. See deployment and lifecycle for scaling and backups, and time and accuracy for identity, count, and reporting semantics.
The solution's tests target .NET 10. To work on the core library alone:
dotnet build src/ZoneTree.Analytics.sln --configuration Release
dotnet test src/ZoneTree.Analytics.sln --configuration Release --no-buildThe library also targets .NET 8 and 9; building all targets requires their targeting packs.
For repository-wide development, use Node.js 22.17 or newer and Corepack. The root package pins Yarn 4.18.1 and manages the dashboard and documentation as workspaces with one lockfile. Run these commands from the repository root:
corepack enable
yarn install
yarn check| Command | Purpose |
|---|---|
yarn check |
Check shared dependencies, lockfile deduplication, lint, formatting, and TypeScript across the repository |
yarn lint / yarn lint:fix |
Check Biome-supported files or apply safe lint and import-order fixes |
yarn format:check / yarn format |
Check or apply the shared formatting style |
yarn build |
Build the .NET solution, dashboard, and documentation site |
yarn test |
Run repository script tests, dashboard tests, browser tracker tests, and .NET tests |
yarn test:web |
Run all TypeScript tests with Vitest |
yarn vitest |
Run all TypeScript tests in watch mode |
yarn dashboard |
Run the .NET dashboard app with its reporting API and UI live reload |
yarn dashboard:dev |
Start the dashboard UI with demo reports |
yarn docs:dev |
Start the documentation development server |
yarn test:web runs every TypeScript test through the root Vitest configuration. The browser tests require Chrome and a built dashboard (yarn workspace zonetree-analytics-dashboard build); the live tracker test also requires a running .NET dashboard (yarn dashboard). Set ZT_DASHBOARD_TEST_URL if the host uses a URL other than http://127.0.0.1:5088. Vitest's file filters and --project unit option are available directly when you want a smaller test run. The documentation build requires the local legal notices described in docs-site/README.md.
After changing dependencies, run yarn dedupe and yarn check-dependencies. Shared declarations must match and resolve to one installed copy. Intentional differences need a reason in the root manifest's dependencyConsistency.exceptions. See the dashboard application guide for additional frontend commands.
Bug reports and focused pull requests are welcome. For a reproducible report, include the package version, runtime, relevant options, and a small example in a GitHub issue.
Released under the MIT license. Built on ZoneTree.
