Skip to content

Commit dcce8e6

Browse files
feat: exit events (#53)
Signed-off-by: Henry <mail@henrygressmann.de>
1 parent 5df3be7 commit dcce8e6

44 files changed

Lines changed: 594 additions & 156 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Since this is not a library, this changelog focuses on the changes that are rele
3030
- Added GeoIP header mappings and presets for Akamai, Cloudflare, CloudFront, Netlify, and Vercel, with MaxMind results taking precedence when available
3131
- Tracker requests now avoid CORS preflight requests by sending JSON as `text/plain` and the event API accepts tracker JSON regardless of content type
3232
- Added external authentication with OpenID Connect, Google, and Microsoft Entra ID, including optional user creation and provider access restrictions
33+
- Improved visit duration accuracy by tracking page exits
3334

3435
### Security
3536

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ arc-swap = "1.9"
2424
futures-lite = { version = "2.6", default-features = false, features = ["alloc"] }
2525
quick_cache = { version = "0.7" }
2626
tokio = { version = "1.53", default-features = false, features = ["macros", "rt-multi-thread", "signal", "sync"] }
27-
tokio-util = { version = "0.7", features = ["io"] }
27+
tokio-util = { version = "0.7", features = ["io", "time"] }
2828

2929
# encoding
3030
hex = { version = "0.4" }

‎README.md‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
<div align="center">
44
<h2>
55
<img float="left" src="./web/public/favicon.svg" width="16px"/>
6-
<a href="https://liwan.dev">liwan.dev</a> - Easy & Privacy-First Web Analytics
6+
<a href="https://liwan.dev">liwan.dev</a> - Self-hosted, privacy-first web analytics
77
</h2>
8-
<div>
8+
<div>
99

1010
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/explodingcamera/liwan/test.yaml?style=flat-square)
1111
![GitHub Release](https://img.shields.io/github/v/release/explodingcamera/liwan?style=flat-square)
@@ -22,18 +22,24 @@
2222

2323
## Features
2424

25-
- **Quick setup**\
26-
Quickly get started with Liwan with a single, self-contained binary . No database or complex setup required. The tracking script is a single line of code that works with any website and less than 1KB in size.
27-
- **Privacy first**\
28-
Liwan respects your users’ privacy by default. No cookies, no cross-site tracking, no persistent identifiers. All data is stored on your server.
29-
- **Lightweight**\
30-
You can run Liwan on a cheap VPS, your old mac mini, or even a Raspberry Pi. Written in Rust and using tokio for async I/O, Liwan is fast and efficient.
31-
- **Open source**\
32-
Fully open source. You can change, extend, and contribute to the codebase.
33-
- **Accurate data**\
34-
Get accurate data about your website’s visitors, page views, referrers, and more. Liwan detects bots and crawlers and filters them out by default.
35-
- **Real-time analytics**\
36-
See your website’s traffic in real-time. Liwan updates the dashboard automatically as new visitors come in.
25+
**Understand your traffic**\
26+
See your most-visited pages, where visitors come from, and how traffic changes over time. The dashboard updates automatically, with bot filtering enabled by default.
27+
28+
**Easy to self-host**\
29+
Run Liwan as a single binary or Docker container. The dashboard and database are built in, with no additional services to manage.
30+
31+
**Privacy first**\
32+
No tracking cookies or cross-site tracking. Your analytics data stays on your server, and you control what’s collected and how long it’s kept.
33+
[Read more about data collection](https://liwan.dev/collected-data/).
34+
35+
**Collect only what you need**\
36+
Choose what data to collect and how long to keep it. Location detail is adjustable, and campaign attribution and session metrics can be disabled independently.
37+
38+
**Lightweight tracking**\
39+
Add a small tracking script to your website with a single line of HTML. Works with any framework or CMS.
40+
41+
**Single sign-on**\
42+
Manage accounts with Google Workspaces, Microsoft Entra, or your own OpenID Connect provider like Keycloak or Dex.
3743

3844
## License
3945

‎src/app/core/events.rs‎

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ use anyhow::{Context, Result};
44
use arc_swap::ArcSwap;
55
use chrono::{DateTime, Local, NaiveTime, TimeZone, Utc};
66
use duckdb::{Connection, Result as DuckResult, params};
7+
use futures_lite::{StreamExt, future};
78
use rand::distr::{SampleString, StandardUniform};
89
use tokio::sync::mpsc::Receiver;
10+
use tokio_util::time::DelayQueue;
911

10-
use crate::app::models::{Event, GeoDetail, ResolvedCollectionSettings, event_params};
12+
use crate::app::models::{Event, EventExit, GeoDetail, ResolvedCollectionSettings, event_params};
1113
use crate::app::{DuckDBPool, SqlitePool};
1214
use crate::utils::duckdb::{ParamVec, repeat_vars};
1315

16+
const EVENT_EXIT_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
17+
1418
#[derive(Clone)]
1519
pub struct LiwanEvents {
1620
duckdb: DuckDBPool,
@@ -97,6 +101,41 @@ impl LiwanEvents {
97101
Ok(())
98102
}
99103

104+
/// Process exit updates after a short delay so queued events can reach shared storage first.
105+
pub async fn process_exits(&self, mut exits_rx: Receiver<EventExit>) -> Result<()> {
106+
let mut pending = DelayQueue::new();
107+
let mut channel_closed = false;
108+
109+
loop {
110+
if channel_closed && pending.is_empty() {
111+
tracing::info!("Event exit channel closed, stopping event exit processing");
112+
return Ok(());
113+
}
114+
115+
tokio::select! {
116+
exit = exits_rx.recv(), if !channel_closed => match exit {
117+
Some(exit) => {
118+
pending.insert(exit, EVENT_EXIT_DELAY);
119+
}
120+
None => channel_closed = true,
121+
},
122+
Some(expired) = pending.next(), if !pending.is_empty() => {
123+
let mut exits = vec![expired.into_inner()];
124+
while let Some(Some(expired)) = future::poll_once(pending.next()).await {
125+
exits.push(expired.into_inner());
126+
}
127+
128+
let count = exits.len();
129+
let events = self.clone();
130+
match tokio::task::spawn_blocking(move || events.update_exits(exits)).await? {
131+
Ok(matched) => tracing::debug!(count, matched, "Processed event exits"),
132+
Err(err) => tracing::error!(?err, "Failed to process event exits"),
133+
}
134+
}
135+
}
136+
}
137+
}
138+
100139
fn process_events_sync(&self, mut events: Receiver<Event>) -> Result<()> {
101140
let mut buffer = Vec::with_capacity(1024);
102141
let conn = self.duckdb.clone();
@@ -139,6 +178,15 @@ impl LiwanEvents {
139178
}
140179
}
141180

181+
fn update_exits(&self, exits: Vec<EventExit>) -> Result<usize> {
182+
let conn = self.duckdb.get().context("Failed to get DuckDB connection")?;
183+
let mut matched = 0;
184+
for exit in exits {
185+
matched += usize::from(update_event_exit(&conn, &exit)?);
186+
}
187+
Ok(matched)
188+
}
189+
142190
/// Preview or apply collection-setting pruning for a single entity
143191
pub fn prune_entity(
144192
&self,
@@ -205,12 +253,14 @@ impl LiwanEvents {
205253
}
206254

207255
if !settings.track_sessions {
208-
let sql = "entity_id = ? and (time_from_last_event is not null or time_to_next_event is not null)";
256+
let sql = "entity_id = ? and (time_from_last_event is not null or time_to_next_event is not null or exited_at is not null)";
209257
stats.cleared_session_events =
210258
count_rows(&conn, &format!("select count(*) from events where {sql}"), params![entity_id])?;
211259
if !dry_run {
212260
conn.execute(
213-
&format!("update events set time_from_last_event = null, time_to_next_event = null where {sql}"),
261+
&format!(
262+
"update events set time_from_last_event = null, time_to_next_event = null, exited_at = null where {sql}"
263+
),
214264
params![entity_id],
215265
)?;
216266
}
@@ -239,6 +289,47 @@ fn count_rows(conn: &Connection, sql: &str, params: impl duckdb::Params) -> Duck
239289
conn.query_row(sql, params, |row| row.get(0))
240290
}
241291

292+
fn update_event_exit(conn: &Connection, exit: &EventExit) -> DuckResult<bool> {
293+
let sql = "--sql
294+
update events
295+
set exited_at = greatest(
296+
coalesce(exited_at, created_at),
297+
least($exited_at::timestamp, created_at + interval '30 minutes')
298+
)
299+
where
300+
entity_id = $entity_id and
301+
visitor_group_id = $visitor_group_id and
302+
event = $event and
303+
fqdn is not distinct from $fqdn and
304+
path is not distinct from $path and
305+
time_to_next_event is null and
306+
created_at = (
307+
select max(candidate.created_at)
308+
from events candidate
309+
where
310+
candidate.entity_id = $entity_id and
311+
candidate.visitor_group_id = $visitor_group_id and
312+
candidate.event = $event and
313+
candidate.fqdn is not distinct from $fqdn and
314+
candidate.path is not distinct from $path and
315+
candidate.time_to_next_event is null and
316+
candidate.created_at <= $exited_at::timestamp and
317+
candidate.created_at >= $exited_at::timestamp - interval '30 minutes'
318+
)";
319+
let updated = conn.execute(
320+
sql,
321+
duckdb::named_params! {
322+
"entity_id": &exit.entity_id,
323+
"visitor_group_id": &exit.visitor_group_id,
324+
"event": &exit.event,
325+
"fqdn": &exit.fqdn,
326+
"path": &exit.path,
327+
"exited_at": exit.created_at,
328+
},
329+
)?;
330+
Ok(updated > 0)
331+
}
332+
242333
fn update_event_times(conn: &Connection, from_time: DateTime<Utc>, entities: &[String]) -> DuckResult<()> {
243334
if entities.is_empty() {
244335
return Ok(());
@@ -281,3 +372,71 @@ fn update_event_times(conn: &Connection, from_time: DateTime<Utc>, entities: &[S
281372
conn.execute(&sql, duckdb::params_from_iter(params))?;
282373
Ok(())
283374
}
375+
376+
#[cfg(test)]
377+
mod tests {
378+
use super::*;
379+
use crate::app::Liwan;
380+
use crate::config::Config;
381+
382+
fn event(created_at: DateTime<Utc>) -> Event {
383+
Event {
384+
entity_id: "entity-1".to_string(),
385+
visitor_group_id: "visitor-1".to_string(),
386+
event: "pageview".to_string(),
387+
created_at,
388+
fqdn: Some("example.com".to_string()),
389+
path: Some("/docs".to_string()),
390+
referrer: None,
391+
platform: None,
392+
browser: None,
393+
mobile: None,
394+
country: None,
395+
city: None,
396+
utm_source: None,
397+
utm_medium: None,
398+
utm_campaign: None,
399+
utm_content: None,
400+
utm_term: None,
401+
screen_width: None,
402+
orientation: None,
403+
track_sessions: true,
404+
}
405+
}
406+
407+
#[test]
408+
fn exit_updates_only_the_latest_event_without_a_next_event() {
409+
let app = Liwan::new_memory(Config::default()).expect("failed to create app");
410+
let first = Utc::now() - chrono::Duration::minutes(2);
411+
let second = first + chrono::Duration::minutes(1);
412+
app.events.append(vec![event(first), event(second)].into_iter()).expect("failed to append events");
413+
414+
let conn = app.events_conn().expect("failed to get event connection");
415+
let matched = update_event_exit(
416+
&conn,
417+
&EventExit {
418+
entity_id: "entity-1".to_string(),
419+
visitor_group_id: "visitor-1".to_string(),
420+
event: "pageview".to_string(),
421+
created_at: second + chrono::Duration::seconds(15),
422+
fqdn: Some("example.com".to_string()),
423+
path: Some("/docs".to_string()),
424+
},
425+
)
426+
.expect("failed to update exit");
427+
428+
assert!(matched);
429+
let rows = conn
430+
.prepare("select exited_at from events order by created_at")
431+
.expect("failed to prepare query")
432+
.query_map([], |row| row.get::<_, Option<DateTime<Utc>>>(0))
433+
.expect("failed to query exits")
434+
.collect::<std::result::Result<Vec<_>, _>>()
435+
.expect("failed to collect exits");
436+
assert_eq!(
437+
rows[1].map(|value| value.timestamp_millis()),
438+
Some((second + chrono::Duration::seconds(15)).timestamp_millis())
439+
);
440+
assert_eq!(rows[0], None);
441+
}
442+
}

‎src/app/core/reports/dimension.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ pub fn dimension_report(
7575
visitor_group_id,
7676
created_at,
7777
time_from_last_event,
78-
time_to_next_event
78+
time_to_next_event,
79+
exited_at
7980
from events sd
8081
where
8182
sd.event = ?::text and

‎src/app/core/reports/graph.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ pub fn overall_report(
167167
e.visitor_group_id,
168168
e.created_at,
169169
e.time_from_last_event,
170-
e.time_to_next_event
170+
e.time_to_next_event,
171+
e.exited_at
171172
from events e
172173
where
173174
e.event = ?::text and
@@ -181,7 +182,8 @@ pub fn overall_report(
181182
sd.visitor_group_id,
182183
sd.created_at,
183184
sd.time_from_last_event,
184-
sd.time_to_next_event
185+
sd.time_to_next_event,
186+
sd.exited_at
185187
from (select * from session_data order by created_at) sd
186188
asof join (select * from time_bins order by bin_start) tb
187189
on sd.created_at >= tb.bin_start

‎src/app/core/reports/shared.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ pub(super) fn metric_aggregate_sql(metric: Metric, alias: &str) -> String {
110110
Metric::AvgTimeOnSite => {
111111
format!(
112112
"--sql
113-
coalesce(avg(extract(epoch from {alias}.time_to_next_event)) filter (where {alias}.time_to_next_event is not null and {alias}.time_to_next_event <= {SESSION_DURATION_SQL}), 0)"
113+
coalesce(avg(extract(epoch from case
114+
when {alias}.time_to_next_event is not null and {alias}.time_to_next_event between interval '0 seconds' and {SESSION_DURATION_SQL}
115+
then {alias}.time_to_next_event
116+
when {alias}.exited_at between {alias}.created_at and {alias}.created_at + {SESSION_DURATION_SQL}
117+
then {alias}.exited_at - {alias}.created_at
118+
end)), 0)"
114119
)
115120
}
116121
}

‎src/app/core/reports/stats.rs‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ pub fn overall_stats(
8484
e.visitor_group_id,
8585
e.created_at,
8686
e.time_from_last_event,
87-
e.time_to_next_event
87+
e.time_to_next_event,
88+
e.exited_at
8889
from events e
8990
where
9091
e.event = ?::text and
@@ -113,3 +114,30 @@ pub fn overall_stats(
113114

114115
Ok(result)
115116
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
use crate::app::Liwan;
122+
use crate::config::Config;
123+
use chrono::Duration;
124+
125+
#[test]
126+
fn overall_stats_uses_terminal_event_exit_duration() {
127+
let app = Liwan::new_memory(Config::default()).expect("failed to create app");
128+
let created_at = Utc::now() - Duration::minutes(5);
129+
let exited_at = created_at + Duration::seconds(90);
130+
let conn = app.events_conn().expect("failed to get event connection");
131+
conn.execute(
132+
"insert into events (entity_id, visitor_group_id, event, created_at, fqdn, path, exited_at) values (?, ?, ?, ?, ?, ?, ?)",
133+
duckdb::params!["entity-1", "visitor-1", "pageview", created_at, "example.com", "/", exited_at],
134+
)
135+
.expect("failed to insert event");
136+
137+
let range = DateRange { start: created_at - Duration::minutes(1), end: exited_at + Duration::minutes(1) };
138+
let stats =
139+
overall_stats(&conn, &["entity-1".to_string()], "pageview", &range, &[]).expect("failed to build stats");
140+
141+
assert_eq!(stats.avg_time_on_site, Some(90.0));
142+
}
143+
}

‎src/app/models.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ pub struct Event {
2929
pub track_sessions: bool,
3030
}
3131

32+
/// Identifies a stored event that should receive an exit timestamp.
33+
#[derive(Debug, Clone)]
34+
pub struct EventExit {
35+
pub entity_id: String,
36+
pub visitor_group_id: String,
37+
pub event: String,
38+
pub created_at: DateTime<Utc>,
39+
pub fqdn: Option<String>,
40+
pub path: Option<String>,
41+
}
42+
3243
#[derive(Debug, Clone)]
3344
pub struct Project {
3445
pub id: String,
@@ -457,6 +468,7 @@ macro_rules! event_params {
457468
None::<std::time::Duration>,
458469
$event.screen_width,
459470
$event.orientation,
471+
None::<chrono::DateTime<chrono::Utc>>,
460472
]
461473
};
462474
}

0 commit comments

Comments
 (0)