Skip to content

Commit bf7c135

Browse files
feat: added new entry/exit page metric
Signed-off-by: Henry <mail@henrygressmann.de>
1 parent 6a628f2 commit bf7c135

File tree

10 files changed

+183
-54
lines changed

10 files changed

+183
-54
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Since this is not a library, this changelog focuses on the changes that are rele
1818

1919
## [Unreleased]
2020

21+
- Added new entry/exit page metric
2122
- Updated to the latest version of DuckDB (1.5.1)
2223
- Fixed error when both `listen` and `port` configuration options are set
2324
- Added retry logic when loading the DuckDB database to handle potential locking issues on startup (e.g. when the database is being updated by another process or when using a shared network drive)

src/app/core/reports.rs

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ use std::collections::BTreeMap;
22
use std::fmt::{Debug, Display};
33

44
use crate::app::DuckDBConn;
5-
use crate::utils::duckdb::{ParamVec, repeat_vars};
6-
use anyhow::{Result, bail};
5+
use crate::utils::duckdb::{repeat_vars, ParamVec};
6+
use anyhow::{bail, Result};
77
use chrono::{DateTime, Utc};
88
use duckdb::params_from_iter;
99
use schemars::JsonSchema;
1010
use serde::{Deserialize, Serialize};
1111

12+
const SESSION_DURATION_SQL: &str = "interval '30 minutes'";
13+
1214
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Hash, PartialEq, Eq)]
1315
pub struct DateRange {
1416
pub start: DateTime<Utc>,
@@ -49,6 +51,8 @@ pub enum Metric {
4951
#[serde(rename_all = "snake_case")]
5052
pub enum Dimension {
5153
Url,
54+
UrlEntry,
55+
UrlExit,
5256
Fqdn,
5357
Path,
5458
Referrer,
@@ -168,6 +172,12 @@ fn filter_sql(filters: &[DimensionFilter]) -> Result<(String, ParamVec<'_>)> {
168172

169173
Ok(match filter.dimension {
170174
Dimension::Url => format!("concat(fqdn, path) {filter_value}"),
175+
Dimension::UrlEntry => format!(
176+
"(time_from_last_event is null or time_from_last_event > {SESSION_DURATION_SQL}) and concat(fqdn, path) {filter_value}"
177+
),
178+
Dimension::UrlExit => format!(
179+
"(time_to_next_event is null or time_to_next_event > {SESSION_DURATION_SQL}) and concat(fqdn, path) {filter_value}"
180+
),
171181
Dimension::Path => format!("path {filter_value}"),
172182
Dimension::Fqdn => format!("fqdn {filter_value}"),
173183
Dimension::Referrer => format!("referrer {filter_value}"),
@@ -190,32 +200,36 @@ fn filter_sql(filters: &[DimensionFilter]) -> Result<(String, ParamVec<'_>)> {
190200

191201
fn metric_sql(metric: Metric) -> String {
192202
match metric {
193-
Metric::Views => "count(sd.created_at)",
203+
Metric::Views => "count(sd.created_at)".to_owned(),
194204
Metric::UniqueVisitors => {
195205
// Count the number of unique visitors as the number of distinct visitor IDs
196206
"--sql
197207
count(distinct sd.visitor_id)"
208+
.to_owned()
198209
}
199210
Metric::BounceRate => {
200211
// total sessions: no time_to_next_event / time_to_next_event is null
201-
// bounce sessions: time to next / time to prev are both null or both > interval '30 minutes'
202-
"--sql
212+
// bounce sessions: time to next / time to prev are both null or both > session duration
213+
format!(
214+
"--sql
203215
coalesce(
204216
count(distinct sd.visitor_id)
205-
filter (where (sd.time_to_next_event is null or sd.time_to_next_event > interval '30 minutes') and
206-
(sd.time_from_last_event is null or sd.time_from_last_event > interval '30 minutes')) /
207-
nullif(count(distinct sd.visitor_id) filter (where sd.time_to_next_event is null or sd.time_to_next_event > interval '30 minutes'), 0),
217+
filter (where (sd.time_to_next_event is null or sd.time_to_next_event > {SESSION_DURATION_SQL}) and
218+
(sd.time_from_last_event is null or sd.time_from_last_event > {SESSION_DURATION_SQL})) /
219+
nullif(count(distinct sd.visitor_id) filter (where sd.time_to_next_event is null or sd.time_to_next_event > {SESSION_DURATION_SQL}), 0),
208220
1
209221
)
210222
"
223+
)
211224
}
212225
Metric::AvgTimeOnSite => {
213-
// avg time_to_next_event where time_to_next_event <= 1800 and time_to_next_event is not null
214-
"--sql
215-
coalesce(avg(extract(epoch from sd.time_to_next_event)) filter (where sd.time_to_next_event is not null and sd.time_to_next_event <= interval '30 minutes'), 0)"
226+
// avg time_to_next_event where time_to_next_event <= session duration and time_to_next_event is not null
227+
format!(
228+
"--sql
229+
coalesce(avg(extract(epoch from sd.time_to_next_event)) filter (where sd.time_to_next_event is not null and sd.time_to_next_event <= {SESSION_DURATION_SQL}), 0)"
230+
)
216231
}
217232
}
218-
.to_owned()
219233
}
220234

221235
pub fn earliest_timestamp(conn: &DuckDBConn, entities: &[String]) -> Result<Option<DateTime<Utc>>> {
@@ -442,21 +456,36 @@ pub fn dimension_report(
442456
let (filters_sql, filters_params) = filter_sql(filters)?;
443457

444458
let metric_column = metric_sql(*metric);
445-
let (dimension_column, group_by_columns) = match dimension {
446-
Dimension::Url => ("concat(fqdn, path)", "fqdn, path"),
447-
Dimension::Path => ("path", "path"),
448-
Dimension::Fqdn => ("fqdn", "fqdn"),
449-
Dimension::Referrer => ("referrer", "referrer"),
450-
Dimension::Platform => ("platform", "platform"),
451-
Dimension::Browser => ("browser", "browser"),
452-
Dimension::Mobile => ("mobile::text", "mobile"),
453-
Dimension::Country => ("country", "country"),
454-
Dimension::City => ("concat(country, city)", "country, city"),
455-
Dimension::UtmSource => ("utm_source", "utm_source"),
456-
Dimension::UtmMedium => ("utm_medium", "utm_medium"),
457-
Dimension::UtmCampaign => ("utm_campaign", "utm_campaign"),
458-
Dimension::UtmContent => ("utm_content", "utm_content"),
459-
Dimension::UtmTerm => ("utm_term", "utm_term"),
459+
let (dimension_column, group_by_columns, dimension_scope_sql) = match dimension {
460+
Dimension::Url => ("concat(fqdn, path)", "fqdn, path", None),
461+
Dimension::UrlEntry => (
462+
"concat(fqdn, path)",
463+
"fqdn, path",
464+
Some(format!("time_from_last_event is null or time_from_last_event > {SESSION_DURATION_SQL}")),
465+
),
466+
Dimension::UrlExit => (
467+
"concat(fqdn, path)",
468+
"fqdn, path",
469+
Some(format!("time_to_next_event is null or time_to_next_event > {SESSION_DURATION_SQL}")),
470+
),
471+
Dimension::Path => ("path", "path", None),
472+
Dimension::Fqdn => ("fqdn", "fqdn", None),
473+
Dimension::Referrer => ("referrer", "referrer", None),
474+
Dimension::Platform => ("platform", "platform", None),
475+
Dimension::Browser => ("browser", "browser", None),
476+
Dimension::Mobile => ("mobile::text", "mobile", None),
477+
Dimension::Country => ("country", "country", None),
478+
Dimension::City => ("concat(country, city)", "country, city", None),
479+
Dimension::UtmSource => ("utm_source", "utm_source", None),
480+
Dimension::UtmMedium => ("utm_medium", "utm_medium", None),
481+
Dimension::UtmCampaign => ("utm_campaign", "utm_campaign", None),
482+
Dimension::UtmContent => ("utm_content", "utm_content", None),
483+
Dimension::UtmTerm => ("utm_term", "utm_term", None),
484+
};
485+
let filters_sql = match (filters_sql.is_empty(), dimension_scope_sql) {
486+
(true, Some(scope)) => format!("and ({scope})"),
487+
(false, Some(scope)) => format!("{filters_sql} and ({scope})"),
488+
(_, None) => filters_sql,
460489
};
461490

462491
params.push(range.start);

web/src/api/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const dimensionNames: Record<Dimension, string> = {
1111
platform: "Platform",
1212
browser: "Browser",
1313
url: "URL",
14+
url_entry: "Entry",
15+
url_exit: "Exit",
1416
path: "Path",
1517
mobile: "Device Type",
1618
referrer: "Referrer",

web/src/api/dashboard.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

web/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export const dimensions = [
1212
"platform",
1313
"browser",
1414
"url",
15+
"url_entry",
16+
"url_exit",
1517
"path",
1618
"mobile",
1719
"referrer",

web/src/components/dimensions/dimensions.module.css

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
.tabs {
99
.tabsList {
1010
display: flex;
11-
gap: 1rem;
11+
gap: 0.5rem;
1212
margin-bottom: 1rem;
1313

1414
.dimensionSelect {
@@ -24,18 +24,50 @@
2424
}
2525
}
2626

27-
.tabsList > button {
27+
.tabsList button {
2828
all: unset;
2929
cursor: pointer;
3030
user-select: none;
31+
opacity: 0.9;
32+
font-weight: 500;
33+
}
34+
35+
.tabsList button[aria-selected="true"] {
36+
opacity: 1;
37+
font-weight: 600;
38+
text-decoration: underline;
39+
text-decoration-thickness: 0.15rem;
40+
}
41+
42+
.tabsList > button:last-of-type {
43+
margin-right: auto;
44+
}
3145

32-
&[aria-selected="true"] {
33-
text-decoration: underline;
34-
font-weight: 600;
46+
.tabsList .pageTabGroup {
47+
display: flex;
48+
align-items: center;
49+
gap: 0.1rem;
50+
51+
> :first-child {
52+
margin-right: 0.1rem;
3553
}
3654

37-
&:last-of-type {
38-
margin-right: auto;
55+
> :not(:first-child) {
56+
font-size: 0.8rem;
57+
padding: 0.1rem 0.1rem;
58+
margin-top: 0.15rem;
59+
60+
&button {
61+
font-weight: 800;
62+
}
63+
64+
&:not([aria-selected="true"]) {
65+
opacity: 0.6;
66+
}
67+
}
68+
69+
> :last-child {
70+
margin-right: 0.5rem;
3971
}
4072
}
4173

web/src/components/dimensions/index.tsx

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,48 @@ export const DimensionTabsCard = ({
4545
);
4646
};
4747

48+
export const PageDimensionTabsCard = ({
49+
query,
50+
onSelect,
51+
}: {
52+
query: ProjectQuery;
53+
onSelect: (value: DimensionTableRow, dimension: Dimension) => void;
54+
}) => {
55+
const showSessionPageDimensions = query.metric === "views" || query.metric === "unique_visitors";
56+
const dimensions: Dimension[] = showSessionPageDimensions
57+
? ["url", "url_entry", "url_exit", "fqdn"]
58+
: ["url", "fqdn"];
59+
60+
return (
61+
<article className={styles.card}>
62+
<Tabs.Root
63+
className={styles.tabs}
64+
defaultValue="url"
65+
key={showSessionPageDimensions ? "session-pages" : "basic-pages"}
66+
>
67+
<Tabs.List className={styles.tabsList}>
68+
<div className={styles.pageTabGroup}>
69+
<Tabs.Tab value="url">URL</Tabs.Tab>
70+
{showSessionPageDimensions && (
71+
<>
72+
<Tabs.Tab value="url_entry">Entry</Tabs.Tab>
73+
<Tabs.Tab value="url_exit">Exit</Tabs.Tab>
74+
</>
75+
)}
76+
</div>
77+
<Tabs.Tab value="fqdn">Domain</Tabs.Tab>
78+
<div>{metricNames[query.metric]}</div>
79+
</Tabs.List>
80+
{dimensions.map((dimension) => (
81+
<Tabs.Panel key={dimension} value={dimension} className={styles.tabsContent}>
82+
<DimensionTable dimension={dimension} query={query} onSelect={(value) => onSelect(value, dimension)} />
83+
</Tabs.Panel>
84+
))}
85+
</Tabs.Root>
86+
</article>
87+
);
88+
};
89+
4890
export const DimensionDropdownCard = ({
4991
dimensions,
5092
query,
@@ -173,6 +215,21 @@ const DimensionValueButton = ({ children, onSelect }: { children: string; onSele
173215
</button>
174216
);
175217

218+
const renderUrlDimensionLabel = (value: DimensionTableRow, onSelect: () => void) => {
219+
const url = tryParseUrl(value.dimensionValue);
220+
221+
return (
222+
<>
223+
<LinkIcon size={16} />
224+
<DimensionValueButton onSelect={onSelect}>{formatPath(url)}</DimensionValueButton>
225+
<a href={getHref(url)} target="_blank" rel="noreferrer" className={styles.external}>
226+
<SquareArrowOutUpRightIcon size={16} />
227+
</a>
228+
{typeof url !== "string" && <span className={styles.hostname}>{formatHost(url)}</span>}
229+
</>
230+
);
231+
};
232+
176233
const dimensionLabels: Record<Dimension, (value: DimensionTableRow, onSelect: () => void) => React.ReactNode> = {
177234
utm_campaign: (value, onSelect) => (
178235
<>
@@ -216,20 +273,9 @@ const dimensionLabels: Record<Dimension, (value: DimensionTableRow, onSelect: ()
216273
<DimensionValueButton onSelect={onSelect}>{value.dimensionValue}</DimensionValueButton>
217274
</>
218275
),
219-
url: (value, onSelect) => {
220-
const url = tryParseUrl(value.dimensionValue);
221-
222-
return (
223-
<>
224-
<LinkIcon size={16} />
225-
<DimensionValueButton onSelect={onSelect}>{formatPath(url)}</DimensionValueButton>
226-
<a href={getHref(url)} target="_blank" rel="noreferrer" className={styles.external}>
227-
<SquareArrowOutUpRightIcon size={16} />
228-
</a>
229-
{typeof url !== "string" && <span className={styles.hostname}>{formatHost(url)}</span>}
230-
</>
231-
);
232-
},
276+
url: renderUrlDimensionLabel,
277+
url_entry: renderUrlDimensionLabel,
278+
url_exit: renderUrlDimensionLabel,
233279
fqdn: (value, onSelect) => {
234280
const url = tryParseUrl(value.dimensionValue);
235281
return (

web/src/components/project.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import type { DateRange } from "../api/ranges";
99

1010
import { useMetric, useRange } from "../hooks/persist";
1111
import { cls } from "../utils";
12-
import { DimensionCard, DimensionDropdownCard, DimensionTabs, DimensionTabsCard } from "./dimensions";
12+
import {
13+
DimensionCard,
14+
DimensionDropdownCard,
15+
DimensionTabs,
16+
DimensionTabsCard,
17+
PageDimensionTabsCard,
18+
} from "./dimensions";
1319
import { LineGraph } from "./graph";
1420
import { SelectFilters } from "./project/filter";
1521
import { SelectMetrics } from "./project/metric";
@@ -112,7 +118,7 @@ export const Project = () => {
112118
<LineGraph data={graph ?? []} metric={metric} title={metricNames[metric]} range={range} />
113119
</article>
114120
<div className={styles.tables}>
115-
<DimensionTabsCard dimensions={["url", "fqdn"]} query={query} onSelect={onSelectDimRow} />
121+
<PageDimensionTabsCard query={query} onSelect={onSelectDimRow} />
116122
<DimensionDropdownCard
117123
dimensions={["referrer", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"]}
118124
query={query}

web/src/components/project/filter.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ const filters = {
5454
invertable: true,
5555
filterTypes: ["equal", "contains", "starts_with", "ends_with"],
5656
},
57+
url_entry: {
58+
invertable: true,
59+
filterTypes: ["equal", "contains", "starts_with", "ends_with"],
60+
},
61+
url_exit: {
62+
invertable: true,
63+
filterTypes: ["equal", "contains", "starts_with", "ends_with"],
64+
},
5765
fqdn: {
5866
invertable: true,
5967
filterTypes: ["equal", "contains", "starts_with", "ends_with"],
@@ -129,6 +137,9 @@ const filters = {
129137
>;
130138

131139
type filterDimension = keyof typeof filters;
140+
const displayFilters = (Object.keys(filters) as filterDimension[]).filter(
141+
(dimension) => dimension !== "url_entry" && dimension !== "url_exit",
142+
);
132143

133144
const FilterDialog = ({ onAdd }: { onAdd: (filter: DimensionFilter) => void }) => {
134145
const closeRef = useRef<HTMLButtonElement>(null);
@@ -171,7 +182,7 @@ const FilterDialog = ({ onAdd }: { onAdd: (filter: DimensionFilter) => void }) =
171182
<label>
172183
Dimension
173184
<select name="dimension" value={dimension} onChange={(e) => setDimension(e.target.value as filterDimension)}>
174-
{Object.keys(filters).map((dimension) => (
185+
{displayFilters.map((dimension) => (
175186
<option key={dimension} value={dimension}>
176187
{dimensionNames[dimension as filterDimension]}
177188
</option>

web/src/global.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
--pico-background-color: #f7f8ff;
77
--card-background-base: 0deg 0% 100%;
88
--pico-card-background-color: hsl(var(--card-background-base));
9-
--pico-block-spacing-vertical: 1rem;
10-
--pico-block-spacing-horizontal: 1rem;
9+
--pico-block-spacing-vertical: 0.8rem;
10+
--pico-block-spacing-horizontal: 0.8rem;
1111
--pico-card-box-shadow: 0px 0px 24px #15233610;
1212
--pico-dropdown-background-color: #ffffff81;
1313

0 commit comments

Comments
 (0)