Skip to content

Commit 32ed53d

Browse files
Merge pull request #223 from erikdarlingdata/dev
Release: analytics for Plan Analyzer web app
2 parents 838f40f + c003007 commit 32ed53d

7 files changed

Lines changed: 1980 additions & 57 deletions

File tree

server/PlanShare/Program.cs

Lines changed: 212 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Concurrent;
22
using System.Security.Cryptography;
3+
using System.Text;
34
using System.Text.Json;
45
using Microsoft.Data.Sqlite;
56

@@ -30,7 +31,15 @@ CREATE TABLE IF NOT EXISTS plans (
3031
created_at TEXT NOT NULL,
3132
expires_at TEXT NOT NULL,
3233
delete_token TEXT NOT NULL
33-
)
34+
);
35+
CREATE TABLE IF NOT EXISTS page_views (
36+
id INTEGER PRIMARY KEY AUTOINCREMENT,
37+
path TEXT NOT NULL,
38+
referrer TEXT,
39+
visitor_hash TEXT NOT NULL,
40+
created_at TEXT NOT NULL
41+
);
42+
CREATE INDEX IF NOT EXISTS idx_pv_created ON page_views(created_at);
3443
""";
3544
cmd.ExecuteNonQuery();
3645
}
@@ -45,8 +54,9 @@ delete_token TEXT NOT NULL
4554
var app = builder.Build();
4655
app.UseCors();
4756

48-
// --- Rate limiter: 10 shares per minute per IP (in-memory) ---
57+
// --- Rate limiters (in-memory) ---
4958
var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
59+
var analyticsRateLimiter = new RateLimiter(maxRequests: 30, windowSeconds: 60);
5060

5161
const int MaxTtlDays = 365;
5262

@@ -124,6 +134,186 @@ delete_token TEXT NOT NULL
124134
return Results.Content(result, "application/json");
125135
});
126136

137+
// --- Analytics: page view tracking ---
138+
139+
app.MapPost("/api/event", async (HttpContext ctx) =>
140+
{
141+
// Rate limit: 30 events/min per IP (generous — covers page nav + shares)
142+
var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
143+
if (!analyticsRateLimiter.IsAllowed(ip))
144+
return Results.StatusCode(429);
145+
146+
using var reader = new StreamReader(ctx.Request.Body);
147+
var body = await reader.ReadToEndAsync();
148+
149+
string path = "/";
150+
string? referrer = null;
151+
try
152+
{
153+
using var doc = JsonDocument.Parse(body);
154+
if (doc.RootElement.TryGetProperty("path", out var p))
155+
path = p.GetString() ?? "/";
156+
if (doc.RootElement.TryGetProperty("referrer", out var r))
157+
referrer = r.GetString();
158+
}
159+
catch (JsonException)
160+
{
161+
return Results.BadRequest("Invalid JSON");
162+
}
163+
164+
// Strip referrer to domain only (no full URLs with query params)
165+
if (!string.IsNullOrEmpty(referrer) && Uri.TryCreate(referrer, UriKind.Absolute, out var refUri))
166+
referrer = refUri.Host;
167+
168+
// Visitor hash: SHA256(IP + User-Agent + date) — unique per day, no PII stored
169+
var ua = ctx.Request.Headers.UserAgent.FirstOrDefault() ?? "";
170+
var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
171+
var visitorHash = Convert.ToHexString(
172+
SHA256.HashData(Encoding.UTF8.GetBytes($"{ip}|{ua}|{day}"))).ToLower()[..16];
173+
174+
using var conn = new SqliteConnection(connectionString);
175+
conn.Open();
176+
using var cmd = conn.CreateCommand();
177+
cmd.CommandText = "INSERT INTO page_views (path, referrer, visitor_hash, created_at) VALUES (@path, @referrer, @hash, @now)";
178+
cmd.Parameters.AddWithValue("@path", path);
179+
cmd.Parameters.AddWithValue("@referrer", (object?)referrer ?? DBNull.Value);
180+
cmd.Parameters.AddWithValue("@hash", visitorHash);
181+
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
182+
cmd.ExecuteNonQuery();
183+
184+
return Results.Ok();
185+
});
186+
187+
app.MapGet("/api/stats", () =>
188+
{
189+
using var conn = new SqliteConnection(connectionString);
190+
conn.Open();
191+
var now = DateTime.UtcNow.ToString("o");
192+
var cutoff30 = DateTime.UtcNow.AddDays(-30).ToString("o");
193+
var cutoff7 = DateTime.UtcNow.AddDays(-7).ToString("o");
194+
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
195+
196+
// --- Plan sharing stats ---
197+
long totalShared;
198+
using (var cmd = conn.CreateCommand())
199+
{
200+
cmd.CommandText = "SELECT COUNT(*) FROM plans";
201+
totalShared = (long)cmd.ExecuteScalar()!;
202+
}
203+
204+
long activePlans;
205+
using (var cmd = conn.CreateCommand())
206+
{
207+
cmd.CommandText = "SELECT COUNT(*) FROM plans WHERE expires_at > @now";
208+
cmd.Parameters.AddWithValue("@now", now);
209+
activePlans = (long)cmd.ExecuteScalar()!;
210+
}
211+
212+
var dailyShares = new List<object>();
213+
using (var cmd = conn.CreateCommand())
214+
{
215+
cmd.CommandText = """
216+
SELECT DATE(created_at) as day, COUNT(*) as count
217+
FROM plans WHERE created_at > @cutoff
218+
GROUP BY DATE(created_at) ORDER BY day
219+
""";
220+
cmd.Parameters.AddWithValue("@cutoff", cutoff30);
221+
using var reader = cmd.ExecuteReader();
222+
while (reader.Read())
223+
dailyShares.Add(new { day = reader.GetString(0), count = reader.GetInt64(1) });
224+
}
225+
226+
// --- Page view stats ---
227+
long viewsToday;
228+
using (var cmd = conn.CreateCommand())
229+
{
230+
cmd.CommandText = "SELECT COUNT(*) FROM page_views WHERE DATE(created_at) = @today";
231+
cmd.Parameters.AddWithValue("@today", today);
232+
viewsToday = (long)cmd.ExecuteScalar()!;
233+
}
234+
235+
long visitorsToday;
236+
using (var cmd = conn.CreateCommand())
237+
{
238+
cmd.CommandText = "SELECT COUNT(DISTINCT visitor_hash) FROM page_views WHERE DATE(created_at) = @today";
239+
cmd.Parameters.AddWithValue("@today", today);
240+
visitorsToday = (long)cmd.ExecuteScalar()!;
241+
}
242+
243+
long views7d;
244+
using (var cmd = conn.CreateCommand())
245+
{
246+
cmd.CommandText = "SELECT COUNT(*) FROM page_views WHERE created_at > @cutoff";
247+
cmd.Parameters.AddWithValue("@cutoff", cutoff7);
248+
views7d = (long)cmd.ExecuteScalar()!;
249+
}
250+
251+
long visitors7d;
252+
using (var cmd = conn.CreateCommand())
253+
{
254+
cmd.CommandText = "SELECT COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at > @cutoff";
255+
cmd.Parameters.AddWithValue("@cutoff", cutoff7);
256+
visitors7d = (long)cmd.ExecuteScalar()!;
257+
}
258+
259+
long views30d;
260+
using (var cmd = conn.CreateCommand())
261+
{
262+
cmd.CommandText = "SELECT COUNT(*) FROM page_views WHERE created_at > @cutoff";
263+
cmd.Parameters.AddWithValue("@cutoff", cutoff30);
264+
views30d = (long)cmd.ExecuteScalar()!;
265+
}
266+
267+
long visitors30d;
268+
using (var cmd = conn.CreateCommand())
269+
{
270+
cmd.CommandText = "SELECT COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at > @cutoff";
271+
cmd.Parameters.AddWithValue("@cutoff", cutoff30);
272+
visitors30d = (long)cmd.ExecuteScalar()!;
273+
}
274+
275+
var dailyViews = new List<object>();
276+
using (var cmd = conn.CreateCommand())
277+
{
278+
cmd.CommandText = """
279+
SELECT DATE(created_at) as day, COUNT(*) as views, COUNT(DISTINCT visitor_hash) as visitors
280+
FROM page_views WHERE created_at > @cutoff
281+
GROUP BY DATE(created_at) ORDER BY day
282+
""";
283+
cmd.Parameters.AddWithValue("@cutoff", cutoff30);
284+
using var reader = cmd.ExecuteReader();
285+
while (reader.Read())
286+
dailyViews.Add(new { day = reader.GetString(0), views = reader.GetInt64(1), visitors = reader.GetInt64(2) });
287+
}
288+
289+
var topReferrers = new List<object>();
290+
using (var cmd = conn.CreateCommand())
291+
{
292+
cmd.CommandText = """
293+
SELECT referrer, COUNT(*) as count
294+
FROM page_views WHERE created_at > @cutoff AND referrer IS NOT NULL AND referrer != ''
295+
GROUP BY referrer ORDER BY count DESC LIMIT 10
296+
""";
297+
cmd.Parameters.AddWithValue("@cutoff", cutoff30);
298+
using var reader = cmd.ExecuteReader();
299+
while (reader.Read())
300+
topReferrers.Add(new { referrer = reader.GetString(0), count = reader.GetInt64(1) });
301+
}
302+
303+
return Results.Json(new
304+
{
305+
sharing = new { total_shared = totalShared, active_plans = activePlans, daily = dailyShares },
306+
traffic = new
307+
{
308+
today = new { views = viewsToday, visitors = visitorsToday },
309+
last_7d = new { views = views7d, visitors = visitors7d },
310+
last_30d = new { views = views30d, visitors = visitors30d },
311+
daily = dailyViews,
312+
top_referrers = topReferrers
313+
}
314+
});
315+
});
316+
127317
app.MapDelete("/api/plans/{id}", (string id, HttpContext ctx) =>
128318
{
129319
var token = ctx.Request.Query["token"].FirstOrDefault();
@@ -188,21 +378,33 @@ private void Cleanup()
188378
{
189379
try
190380
{
191-
var now = DateTime.UtcNow.ToString("o");
192381
using var conn = new SqliteConnection(_config.ConnectionString);
193382
conn.Open();
194-
using var cmd = conn.CreateCommand();
195-
cmd.CommandText = "DELETE FROM plans WHERE expires_at < @now";
196-
cmd.Parameters.AddWithValue("@now", now);
197-
var deleted = cmd.ExecuteNonQuery();
198-
if (deleted > 0)
383+
384+
// Delete expired plans
385+
var now = DateTime.UtcNow.ToString("o");
386+
using (var cmd = conn.CreateCommand())
387+
{
388+
cmd.CommandText = "DELETE FROM plans WHERE expires_at < @now";
389+
cmd.Parameters.AddWithValue("@now", now);
390+
var deleted = cmd.ExecuteNonQuery();
391+
if (deleted > 0)
392+
_logger.LogInformation("Cleaned up {Count} expired plans", deleted);
393+
}
394+
395+
// Delete page views older than 90 days
396+
using (var cmd = conn.CreateCommand())
199397
{
200-
_logger.LogInformation("Cleaned up {Count} expired plans", deleted);
398+
cmd.CommandText = "DELETE FROM page_views WHERE created_at < @cutoff";
399+
cmd.Parameters.AddWithValue("@cutoff", DateTime.UtcNow.AddDays(-90).ToString("o"));
400+
var deleted = cmd.ExecuteNonQuery();
401+
if (deleted > 0)
402+
_logger.LogInformation("Cleaned up {Count} old page views", deleted);
201403
}
202404
}
203405
catch (Exception ex)
204406
{
205-
_logger.LogError(ex, "Error during plan cleanup");
407+
_logger.LogError(ex, "Error during cleanup");
206408
}
207409
}
208410
}

0 commit comments

Comments
 (0)