Measuring Product Metrics in Code: Event Tracking and Cohort Analysis with Angular and ASP.NET Core
Product metrics start with an event taxonomy that lives in code: object_action names, an allowed property list and a schema version. We write a consent-aware AnalyticsService in Angular and an event table with an ASP.NET Core Minimal API and EF Core, compute a weekly retention cohort and an ordered activation funnel in SQL Server with real query output, and cover KVKK/GDPR concerns and a ready-made tool versus your own table.
Version note: The code in this post was built with .NET 10 and EF (Entity Framework) Core 10.0.14 and run against SQL (Structured Query Language) Server 2025; the Angular service was type-checked with Angular 22.2.5 and TypeScript 6.0.3.
Guess First
Three teams at Bookstore started product analytics in the same month. The catalog team called its event AddToCart, the mobile web team cart-add, and the campaign team Sepete Eklendi. Three months later the product owner asks: "How many people added a book to the cart last quarter?"
Can you answer that with a single query? And consider this: so it could match events to customers later, the campaign team also added the customer's e-mail address as a property on every event. Is that a convenience or a problem?
Event Taxonomy: Names First
Events are the raw material of product metrics: a record of something meaningful the user did in the product. Before scattering events through the code, you need to write an event taxonomy, that is, define in one place which events are sent, under which names, with which properties. The three names at the start show why this matters: if the same behavior has three names, counting it means three queries, three mappings and endless arguments.
A common, simple rule is object_action naming: the object of the behavior first, then the action in past tense, lowercase with underscores. book_viewed, cart_item_added, checkout_started, order_placed, account_created. This rule puts events about the same object next to each other in an alphabetical list and removes the "was it added or will it be added?" ambiguity.
Properties are part of the taxonomy too, with two rules. First, allowed properties live in a list; a property not on the list is rejected. Second, when an event's schema changes, its version goes up. At Bookstore the second version of cart_item_added added a source property saying which page the item was added from; v1 and v2 are accepted together for a while, and queries are written with the version in mind.
Server: Minimal API and EF Core
We collect events with a single Minimal API (Application Programming Interface) endpoint in ASP.NET Core and write them to a SQL Server table with EF Core. The taxonomy lives in code, in the EventCatalog class; that way the event list lives not on a wiki page but in code that is compiled and reviewed:
using System.Collections.Frozen;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Analytics;
public sealed class ProductEvent
{
public long Id { get; set; }
public required string Name { get; set; }
public int SchemaVersion { get; set; }
public Guid AnonymousId { get; set; }
public int? CustomerId { get; set; }
public DateTime OccurredAtUtc { get; set; }
public string PropertiesJson { get; set; } = "{}";
}
public sealed class AnalyticsDbContext(DbContextOptions<AnalyticsDbContext> options) : DbContext(options)
{
public DbSet<ProductEvent> Events => Set<ProductEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProductEvent>(e =>
{
e.ToTable("ProductEvents");
e.Property(x => x.Name).HasMaxLength(64).IsUnicode(false);
e.Property(x => x.OccurredAtUtc).HasColumnType("datetime2(0)");
e.HasIndex(x => new { x.Name, x.OccurredAtUtc });
e.HasIndex(x => x.CustomerId);
});
}
}
public static class EventCatalog
{
// object_action naming; the version goes up when the schema changes, the old one is accepted for a while
public static readonly FrozenDictionary<string, int[]> Versions = new Dictionary<string, int[]>
{
["account_created"] = [1],
["book_viewed"] = [1],
["cart_item_added"] = [1, 2],
["checkout_started"] = [1],
["order_placed"] = [1],
}.ToFrozenDictionary();
// Only these properties are accepted: PII such as e-mail, name or address can't get in
public static readonly FrozenSet<string> AllowedProperties =
new[] { "book_id", "price_bucket", "source", "step" }.ToFrozenSet();
}
public sealed record TrackEventRequest(
string Name,
int SchemaVersion,
Guid AnonymousId,
Dictionary<string, string>? Properties);
The table is deliberately narrow: the event's name, version, anonymous id, customer id if any, time, and properties as JSON (JavaScript Object Notation). The (Name, OccurredAtUtc) index serves the filter funnel and cohort queries use most, and the CustomerId index serves the cohort join. The endpoint:
using System.Security.Claims;
using System.Text.Json;
using Bookstore.Analytics;
using Microsoft.EntityFrameworkCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AnalyticsDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Analytics")));
builder.Services.AddSingleton(TimeProvider.System);
WebApplication app = builder.Build();
app.MapPost("/api/events", async (
TrackEventRequest request,
ClaimsPrincipal user,
AnalyticsDbContext db,
TimeProvider clock,
CancellationToken ct) =>
{
Dictionary<string, string> properties = request.Properties ?? [];
Dictionary<string, string[]> errors = [];
if (!EventCatalog.Versions.TryGetValue(request.Name, out int[]? versions)
|| !versions.Contains(request.SchemaVersion))
errors["name"] = [$"Unknown event '{request.Name}' v{request.SchemaVersion}."];
string[] unknown = [.. properties.Keys.Where(k => !EventCatalog.AllowedProperties.Contains(k))];
if (unknown.Length > 0)
errors["properties"] = [$"Not allowed: {string.Join(", ", unknown)}."];
if (errors.Count > 0)
return Results.ValidationProblem(errors);
// The customer id comes from the session, not from the client
int? customerId = int.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out int id) ? id : null;
db.Events.Add(new ProductEvent
{
Name = request.Name,
SchemaVersion = request.SchemaVersion,
AnonymousId = request.AnonymousId,
CustomerId = customerId,
// The server stamps the time: we don't trust the client clock
OccurredAtUtc = clock.GetUtcNow().UtcDateTime,
PropertiesJson = JsonSerializer.Serialize(properties),
});
await db.SaveChangesAsync(ct);
return Results.Accepted();
});
using (IServiceScope scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<AnalyticsDbContext>().Database.EnsureCreatedAsync();
app.Run();
Three decisions are worth noticing. The customer id comes from the session's claim, not the request body; a client can't send events on behalf of another customer. The server stamps the time, because the user's computer clock may be wrong. Unknown events and disallowed properties are rejected with a 400; seeing the error early is cheaper than polluting analytics data. EnsureCreatedAsync keeps this example short; in a real project you manage the schema with the migrations described in the EF Core series. We pass the connection string through the ConnectionStrings__Analytics environment variable.
Let's run the API and send two requests: one valid, one carrying both an unknown version and an e-mail property:
$ curl -i -X POST http://localhost:5317/api/events -H "Content-Type: application/json" \
-d '{"name":"cart_item_added","schemaVersion":2,"anonymousId":"3f2b8c4e-6a1d-4e2f-9b7a-0c5d1e8f4a21","properties":{"book_id":"9780134685991","source":"book_page"}}'
HTTP/1.1 202 Accepted
Content-Length: 0
Date: Sun, 08 Nov 2026 09:28:39 GMT
Server: Kestrel
$ curl -i -X POST http://localhost:5317/api/events -H "Content-Type: application/json" \
-d '{"name":"cart_item_added","schemaVersion":3,"anonymousId":"3f2b8c4e-6a1d-4e2f-9b7a-0c5d1e8f4a21","properties":{"book_id":"9780134685991","email":"ada@example.com"}}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 09:28:39 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"name":["Unknown event 'cart_item_added' v3."],"properties":["Not allowed: email."]}}
The first request returns 202 Accepted; the second is rejected with two separate validation errors. The row written to the table:
Id Name SchemaVersion CustomerId OccurredAtUtc PropertiesJson
-- ---- ------------- ---------- ------------- --------------
83771 cart_item_added 2 NULL 2026-11-08 09:28:40 {"book_id":"9780134685991","source":"book_page"}
CustomerId is empty because we sent it with curl without signing in. OccurredAtUtc is rounded to the second: we defined the column as datetime2(0), and product analytics doesn't need milliseconds.
Client: AnalyticsService in Angular
On the Angular side we gather event sending in a single service. Components don't call HttpClient directly; they say analytics.track('cart_item_added', { book_id: book.isbn, source: 'book_page' }):
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
// Event names follow object_action and match EventCatalog on the server
export type EventName =
| 'account_created'
| 'book_viewed'
| 'cart_item_added'
| 'checkout_started'
| 'order_placed';
type PropertyKey = 'book_id' | 'price_bucket' | 'source' | 'step';
export type EventProperties = Partial<Record<PropertyKey, string>>;
const SCHEMA_VERSIONS: Record<EventName, number> = {
account_created: 1,
book_viewed: 1,
cart_item_added: 2,
checkout_started: 1,
order_placed: 1,
};
@Injectable({ providedIn: 'root' })
export class ConsentService {
// The "analytics" choice from the cookie banner; off by default
readonly analyticsAllowed = signal(false);
}
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly http = inject(HttpClient);
private readonly consent = inject(ConsentService);
private readonly storageKey = 'bs_anon_id';
track(name: EventName, properties: EventProperties = {}): void {
// Without consent, no id is written to the device and no request is sent
if (!this.consent.analyticsAllowed()) {
return;
}
this.http
.post('/api/events', {
name,
schemaVersion: SCHEMA_VERSIONS[name],
anonymousId: this.anonymousId(),
properties,
})
.subscribe({
// An analytics failure must never break the user's flow
error: () => undefined,
});
}
private anonymousId(): string {
let id = localStorage.getItem(this.storageKey);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(this.storageKey, id);
}
return id;
}
}
EventName and the property keys are written as union types; track('AddToCart') or { email: '...' } is a compile error. That way the opening problem of three different names is caught by the compiler before anything reaches the server. The allowlist on the server is the second line of defense, because not every client is our Angular code.
The consent check is the service's first line: if the user didn't allow analytics in the cookie banner, no anonymous id is written to localStorage and no request is sent. The anonymous id is generated with crypto.randomUUID() and doesn't identify the person. The request's error is swallowed on purpose: analytics failing must never stop a user from adding a book to the cart. We type-checked this service with Angular 22.2.5 packages and TypeScript 6.0.3; we didn't run it end to end in a browser inside an Angular app, and we showed the request reaching the endpoint with curl above.
The Weekly Retention Cohort
Seeing the value of the data takes several weeks, not one. So we loaded eight weeks of simulated event data, 7 September to 1 November 2026, into the database: 83,770 events, 4,029 registered customers and guests who never signed up. The data isn't from real customers but generated with realistic rates; the numbers below are the real output of queries running on that data.
A retention cohort groups users by the week they started and shows how many in each group come back in later weeks. The query uses the DATETRUNC function that arrived in SQL Server 2022; iso_week starts the week on Monday:
/* Weekly retention cohort: group by sign-up week, count who is active in later weeks */
DECLARE @lastFullWeek date = '2026-10-26';
WITH cohorts AS (
SELECT CustomerId, CAST(DATETRUNC(iso_week, MIN(OccurredAtUtc)) AS date) AS CohortWeek
FROM dbo.ProductEvents
WHERE Name = 'account_created'
GROUP BY CustomerId
),
activity AS (
SELECT DISTINCT e.CustomerId,
DATEDIFF(week, c.CohortWeek, CAST(DATETRUNC(iso_week, e.OccurredAtUtc) AS date)) AS WeekNo
FROM dbo.ProductEvents AS e
JOIN cohorts AS c ON c.CustomerId = e.CustomerId
WHERE e.Name <> 'account_created'
)
SELECT CONVERT(char(10), c.CohortWeek, 23) AS Cohort,
COUNT(DISTINCT c.CustomerId) AS Users,
CAST(100.0 * COUNT(DISTINCT CASE WHEN a.WeekNo = 1 THEN a.CustomerId END)
/ COUNT(DISTINCT c.CustomerId) AS decimal(4, 1)) AS W1,
CASE WHEN DATEADD(week, 2, c.CohortWeek) <= @lastFullWeek THEN
CAST(100.0 * COUNT(DISTINCT CASE WHEN a.WeekNo = 2 THEN a.CustomerId END)
/ COUNT(DISTINCT c.CustomerId) AS decimal(4, 1)) END AS W2,
CASE WHEN DATEADD(week, 4, c.CohortWeek) <= @lastFullWeek THEN
CAST(100.0 * COUNT(DISTINCT CASE WHEN a.WeekNo = 4 THEN a.CustomerId END)
/ COUNT(DISTINCT c.CustomerId) AS decimal(4, 1)) END AS W4
FROM cohorts AS c
LEFT JOIN activity AS a ON a.CustomerId = c.CustomerId
WHERE DATEADD(week, 1, c.CohortWeek) <= @lastFullWeek
GROUP BY c.CohortWeek
ORDER BY c.CohortWeek;
The real output:
Cohort Users W1 W2 W4
------ ----- -- -- --
2026-09-07 526 39.4 23.4 21.3
2026-09-14 510 37.1 23.3 23.1
2026-09-21 454 39.6 27.3 21.1
2026-09-28 555 42.7 25.2 21.1
2026-10-05 454 42.1 26.4 NULL
2026-10-12 539 39.3 27.5 NULL
2026-10-19 470 39.1 NULL NULL
(7 rows affected)
Read the table like this: of the 526 customers who signed up in the week of 7 September, 39.4% were active again the next week, 23.4% two weeks later and 21.3% four weeks later. The NULLs are cells that can't be measured yet: the fourth week of the 5 October cohort isn't complete. Writing these incomplete weeks as zero makes the newest cohorts look the worst and is a common mistake; the @lastFullWeek check in the query prevents it.
The real thing to read is the curve: retention drops to 37–43% in the first week and flattens around 21–27% after the second week. A flattening curve says the product delivers value to a core group of users. There's no clear trend between cohorts; the September cohort behaves like October's. If a new feature improves retention, you'll see it in this table in the rows of the cohorts after the feature.
The Activation Funnel
A funnel shows how many users complete consecutive steps. The critical part of the query is ordering: a user counts as "added to cart" only if the add-to-cart event came after a product view. Otherwise an order-independent count lets users who skipped steps into the funnel too:
/* Activation funnel: every step must happen AFTER the previous one */
WITH steps AS (
SELECT AnonymousId,
MIN(CASE WHEN Name = 'book_viewed' THEN OccurredAtUtc END) AS Viewed,
MIN(CASE WHEN Name = 'cart_item_added' THEN OccurredAtUtc END) AS Carted,
MIN(CASE WHEN Name = 'checkout_started' THEN OccurredAtUtc END) AS CheckedOut,
MIN(CASE WHEN Name = 'order_placed' THEN OccurredAtUtc END) AS Ordered
FROM dbo.ProductEvents
WHERE OccurredAtUtc >= '2026-10-05' AND OccurredAtUtc < '2026-11-02'
GROUP BY AnonymousId
),
funnel AS (
SELECT COUNT(Viewed) AS Viewed,
COUNT(CASE WHEN Carted > Viewed THEN 1 END) AS Carted,
COUNT(CASE WHEN CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS CheckedOut,
COUNT(CASE WHEN Ordered > CheckedOut AND CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS Ordered
FROM steps
)
SELECT Viewed, Carted, CheckedOut, Ordered,
CAST(100.0 * Ordered / Viewed AS decimal(4, 1)) AS ConversionPct,
CAST(100.0 - 100.0 * Ordered / Carted AS decimal(4, 1)) AS CartAbandonmentPct
FROM funnel;
Viewed Carted CheckedOut Ordered ConversionPct CartAbandonmentPct
------ ------ ---------- ------- ------------- ------------------
11279 5192 3116 1729 15.3 66.7
(1 rows affected)
In the last four weeks, of 11,279 anonymous visitors who viewed a book, 5,192 added one to the cart and 1,729 completed an order. 66.7% of those who added to the cart left without completing the order: exactly the number we discussed as the cart abandonment problem in earlier posts. Splitting the same query into guests and registered customers:
/* The same funnel, split into guests and registered customers */
WITH steps AS (
SELECT AnonymousId,
CASE WHEN MAX(CustomerId) IS NULL THEN 'guest' ELSE 'registered' END AS Segment,
MIN(CASE WHEN Name = 'book_viewed' THEN OccurredAtUtc END) AS Viewed,
MIN(CASE WHEN Name = 'cart_item_added' THEN OccurredAtUtc END) AS Carted,
MIN(CASE WHEN Name = 'checkout_started' THEN OccurredAtUtc END) AS CheckedOut,
MIN(CASE WHEN Name = 'order_placed' THEN OccurredAtUtc END) AS Ordered
FROM dbo.ProductEvents
WHERE OccurredAtUtc >= '2026-10-05' AND OccurredAtUtc < '2026-11-02'
GROUP BY AnonymousId
)
SELECT Segment,
COUNT(CASE WHEN Carted > Viewed THEN 1 END) AS Carted,
COUNT(CASE WHEN CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS CheckedOut,
COUNT(CASE WHEN Ordered > CheckedOut AND CheckedOut > Carted AND Carted > Viewed THEN 1 END) AS Ordered,
CAST(100.0 - 100.0 * COUNT(CASE WHEN Ordered > CheckedOut AND CheckedOut > Carted AND Carted > Viewed THEN 1 END)
/ COUNT(CASE WHEN Carted > Viewed THEN 1 END) AS decimal(4, 1)) AS CartAbandonmentPct
FROM steps
GROUP BY Segment
ORDER BY Segment;
Segment Carted CheckedOut Ordered CartAbandonmentPct
------- ------ ---------- ------- ------------------
guest 3424 1998 957 72.1
registered 1768 1118 772 56.3
(2 rows affected)
Abandonment is 72.1% for guests and 56.3% for registered customers. Two thirds of those adding to the cart are guests. The numbers in the problem statement in post 02 and in the Opportunity Solution Tree in post 03 come from here; and this table also shows why the one-click checkout idea isn't at the top of the list: saving cards addresses registered customers, while most abandonment comes from guests.
Privacy: What to Watch Under KVKK and GDPR
When you keep your own event table and process personal data, you have obligations under KVKK (Turkey's Personal Data Protection Law, No. 6698) in Turkey and under the GDPR (General Data Protection Regulation) for your users in Europe. This isn't legal advice; it's a checklist of topics to discuss with your company's legal team:
- Don't send PII (Personally Identifiable Information): e-mail, name, phone and address are not event properties. That's exactly why the campaign team's e-mail property is a problem; the server's allowlist rejects it.
- Consent: if you use cookies or a device id for analytics, get the user's permission, and send nothing without it.
- Retention period: decide how long you keep events and automate it; for example, a scheduled job that deletes rows older than 13 months.
- Don't record IP (Internet Protocol) addresses and user agents unless you need them; our table has neither.
- Deletion requests: when a customer asks for their data to be deleted, you must be able to delete or anonymize their events by
CustomerId.
A ready-made product analytics tool (such as PostHog, Amplitude or Mixpanel) brings some of these topics, funnel and cohort screens, bot filtering and scaling out of the box; in exchange your data lives with a third party and the cost grows with event volume. Your own table gives you full control and the flexibility of SQL, but you write the screens, the scaling and the privacy processes yourself. For most teams a reasonable start is a tool, with a taxonomy the team owns itself; the taxonomy is tool-independent and stays valid when the tool changes.
Product analytics and observability are not the same thing either. The logs, metrics and traces the Observability series will cover describe how the system works: a request's duration, an exception, a pod's memory. Product analytics describes what the user does. The two may share a database or a pipeline, but their owners, retention periods and privacy rules differ.
In Production
Single inserts get expensive as volume grows. Once you reach a few hundred thousand events a day, you need to turn the endpoint into something that writes to a queue with batch inserts behind it, and partition the table by date. The patterns in the Working with Message Queues series apply directly here.
Some events never arrive. Because of ad blockers, users who don't consent and people who close the page before the request finishes, client-side events are always incomplete. Count critical numbers like orders themselves from the orders table, not from events; use events for rates and behavior.
Filter out bot traffic. Search engine bots and browser automation inflate the book_viewed count. Filtering known bot user agents on the server and writing that into the metric definition is necessary.
Watch the time zone. Events are stored in UTC (Coordinated Universal Time); since Turkey is UTC+3, orders placed after 22:00 can fall on the next day in UTC. The effect on weekly cohorts is small, but for daily reports state clearly which time zone draws the day boundary.
Common Pitfalls
The first pitfall is starting without a taxonomy. If every team names its events its own way, a few months later your data carries a hundred different names and no shared meaning. Writing the taxonomy into code and putting it through review is the cheapest way to prevent that.
The second is tracking everything. Sending every click as an event grows noise, not data. Start from the metric tree in the previous post and define only the events needed to compute those metrics.
The third is counting an incomplete period as zero. Showing a week that hasn't arrived yet as 0% in the cohort table makes the newest cohort look the worst and leads to wrong conclusions like "the latest change broke retention."
The fourth is an unordered funnel. A funnel that doesn't check the order of steps also counts as "converted" a user who placed an order and then came back to the same book's page, and makes rates look better than they are.
A Small Experiment
★☆☆ Add a search_performed event and a result_count property to EventCatalog, and update the union types in the Angular service. What happens if you forget to add a property on the server and add it only on the client?
★★☆ Rewrite the cohort query around "placing an order" instead of "being active": the share of each cohort that places an order in later weeks. How does the curve differ from the activity curve?
★★★ Write a scheduled job (with BackgroundService or Hangfire) that deletes events older than 13 months, and delete in batches so it doesn't cause locking problems on a big table. Log the number of rows deleted.
We've collected events and measured the funnel. In the next post we'll learn to decide with this data: how to measure with an A/B test whether a change like the shipping cost on the product page really works, sample size, the peeking problem and sample ratio mismatch, with Microsoft.FeatureManagement.