Skip to content

Commit 5a47a33

Browse files
committed
chg(coat): user statisitcs data from plausible
1 parent 995d2c8 commit 5a47a33

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,8 @@ SERVICES_ORG=ninanor
8989
SERVICES_STORAGE_PREFIX=/dms/tables
9090
GIT_USERNAME=
9191
GIT_TOKEN=
92+
93+
# COAT
94+
COAT_API_KEY=
95+
COAT_BASE_URL=
96+
COAT_PLAUSIBLE_API_KEY=

src/datasync/coat.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import dlt
2+
import requests
3+
import typer
4+
5+
from .settings import log
6+
7+
app = typer.Typer(help="Export COAT data to parquet")
8+
9+
PLAUSIBLE_BASE_URL = "https://plausible.io/api/v2"
10+
PLAUSIBLE_METRICS = ["visitors", "visits", "pageviews", "bounce_rate", "visit_duration"]
11+
PLAUSIBLE_SITE_ID = "data.coat.no"
12+
13+
_BREAKDOWN_DIMENSIONS = [
14+
("page", "event:page"),
15+
("source", "visit:source"),
16+
("country", "visit:country_name"),
17+
("device", "visit:device"),
18+
("browser", "visit:browser"),
19+
("os", "visit:os"),
20+
]
21+
22+
23+
def _plausible_paginate(api_key: str, base_payload: dict, page_size: int = 10_000):
24+
"""Yield all result rows from a Plausible v2 query, handling pagination."""
25+
offset = 0
26+
while True:
27+
payload = {
28+
**base_payload,
29+
"include": {"total_rows": True},
30+
"pagination": {"limit": page_size, "offset": offset},
31+
}
32+
response = requests.post(
33+
f"{PLAUSIBLE_BASE_URL}/query",
34+
headers={
35+
"Authorization": f"Bearer {api_key}",
36+
"Content-Type": "application/json",
37+
},
38+
json=payload,
39+
timeout=60,
40+
)
41+
response.raise_for_status()
42+
data = response.json()
43+
results = data.get("results", [])
44+
if not results:
45+
break
46+
yield from results
47+
total_rows = data.get("meta", {}).get("total_rows", 0)
48+
offset += len(results)
49+
if offset >= total_rows:
50+
break
51+
52+
53+
@dlt.resource(
54+
name="plausible_timeseries",
55+
primary_key="date",
56+
write_disposition="replace",
57+
)
58+
def plausible_timeseries(api_key: str, site_id: str = PLAUSIBLE_SITE_ID):
59+
"""Fetch daily timeseries stats from Plausible Analytics."""
60+
log.info(f"Fetching Plausible timeseries for {site_id}")
61+
for row in _plausible_paginate(
62+
api_key,
63+
{
64+
"site_id": site_id,
65+
"metrics": PLAUSIBLE_METRICS,
66+
"date_range": "all",
67+
"dimensions": ["time:day"],
68+
"order_by": [["time:day", "asc"]],
69+
},
70+
):
71+
keys = ["date", *PLAUSIBLE_METRICS]
72+
vals = [*row["dimensions"], *row["metrics"]]
73+
yield dict(zip(keys, vals, strict=True))
74+
75+
76+
@dlt.resource(
77+
name="plausible_breakdown",
78+
primary_key=["dimension", "value"],
79+
write_disposition="replace",
80+
)
81+
def plausible_breakdown(api_key: str, site_id: str = PLAUSIBLE_SITE_ID):
82+
"""Fetch breakdowns by page, source, country, device, browser, and OS."""
83+
for dim_name, dim_key in _BREAKDOWN_DIMENSIONS:
84+
log.info(f"Fetching Plausible breakdown: {dim_name} ({dim_key})")
85+
for row in _plausible_paginate(
86+
api_key,
87+
{
88+
"site_id": site_id,
89+
"metrics": PLAUSIBLE_METRICS,
90+
"date_range": "all",
91+
"dimensions": [dim_key],
92+
},
93+
):
94+
yield {
95+
"dimension": dim_name,
96+
"value": row["dimensions"][0],
97+
**dict(zip(PLAUSIBLE_METRICS, row["metrics"], strict=True)),
98+
}
99+
100+
101+
@dlt.source(name="coat_plausible", max_table_nesting=0)
102+
def plausible_source(api_key: str, site_id: str = PLAUSIBLE_SITE_ID):
103+
"""Define the Plausible Analytics data source for a COAT site."""
104+
return (
105+
plausible_timeseries(api_key=api_key, site_id=site_id),
106+
plausible_breakdown(api_key=api_key, site_id=site_id),
107+
)
108+
109+
110+
@app.command()
111+
def get_plausible_analytics(
112+
bucket_url: str = typer.Option(
113+
default="data",
114+
envvar="COAT_BUCKET_URL",
115+
help="Destination bucket URL (local path or s3://...)",
116+
),
117+
api_key: str = typer.Option(
118+
...,
119+
envvar="COAT_PLAUSIBLE_API_KEY",
120+
help="Plausible Stats API key",
121+
),
122+
site_id: str = typer.Option(
123+
default=PLAUSIBLE_SITE_ID,
124+
envvar="COAT_PLAUSIBLE_SITE_ID",
125+
help="Plausible site ID (e.g. data.coat.no)",
126+
),
127+
):
128+
"""Run the Plausible analytics pipeline for data.coat.no."""
129+
pipeline = dlt.pipeline(
130+
pipeline_name="coat_plausible",
131+
destination=dlt.destinations.filesystem(
132+
bucket_url=bucket_url, layout="{table_name}.{ext}"
133+
),
134+
dataset_name="coat_plausible",
135+
)
136+
pipeline.run(
137+
plausible_source(api_key=api_key, site_id=site_id),
138+
loader_file_format="parquet",
139+
)
140+
log.info(f"Plausible pipeline complete. Data at: {bucket_url}/coat_plausible")
141+
142+
143+
if __name__ == "__main__":
144+
app()

src/datasync/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import typer
66

77
from . import (
8+
coat,
89
dms,
910
gbif_backbone,
1011
grass,
@@ -30,6 +31,7 @@
3031
app.add_typer(gbif_backbone.app, name="gbif-backbone")
3132
app.add_typer(ipt_app, name="ipt")
3233
app.add_typer(mediebank.app, name="mediebank")
34+
app.add_typer(coat.app, name="coat")
3335

3436
if __name__ == "__main__":
3537
app()

0 commit comments

Comments
 (0)