Querying event data
The events your data collection sends are archived on S3 as Parquet, and the usual way to analyse them from a task or a notebook is DuckDB — it reads the Parquet directly from S3, so there is no cluster to spin up and no data to copy first.
pipe_algorithms_lib.duckdb_utils provides the three pieces you need:
| Function | Purpose |
|---|---|
prepare_parquet_path(codops, sitekey, days, event_type) |
resolve the S3 paths for a lookback window into a read_parquet(...) expression |
execute_query(sql) |
run a SQL string, get back a list[tuple] |
get_duckdb_connection() |
the cached connection, if you ever need it directly |
duckdb (and is-bot, if you use the precise bot filter below) has to be in
your environment.
A first query
from pipe_algorithms_lib.duckdb_utils import execute_query, prepare_parquet_path
CODOPS = "your_codops"
SITEKEY = "your_sitekey" # e.g. dedw000000000128
parquet = prepare_parquet_path(
codops=CODOPS,
sitekey=SITEKEY,
days=1, # 0 = today only, 1 = today + yesterday
event_type="media_play", # or page_view, or "*" for everything
)
rows = execute_query(f"""
SELECT event['props']['pageType'] AS model_type,
COUNT(*) AS event_count
FROM {parquet}
GROUP BY model_type
""")
prepare_parquet_path returns a string, so it goes straight into the FROM
clause of an f-string query.
Where the data actually sits
s3://peach-members-{codops}/events_by_type/{event_type}/
daily/{sitekey}_events/{YYYY-MM-DD}/*.parquet ← past days
hourly/{sitekey}_events/{YYYY-MM-DD}/*.parquet ← today
Today is written hourly and past days are consolidated daily;
prepare_parquet_path picks the right granularity per day for you. With
event_type="*" it reads the combined peach-events prefix instead of a
per-type one.
The fields you will use
The Parquet columns are nested, and addressed with ['...']:
| Expression | What it is |
|---|---|
event['metadata']['id'] |
content id |
event['props']['pageType'] |
content type — ARTICLE, VIDEO, AUDIO, … |
event['metadata']['appName'] |
source app, e.g. nest, native-mobile |
event['props']['categories'][1] |
category id — arrays are 1-indexed |
event['props']['regions'][1] |
region, formatted CONTINENT::COUNTRY (EUROPE::DE) |
client['id'] |
device / user id |
client['device']['type'] |
device type — what bot filtering looks at |
collect_timestamp |
event time, in milliseconds |
Two rules for any ranking query
Filter out bots
Bot traffic will otherwise dominate whatever you rank. A single ILIKE handles
the overwhelming majority:
WHERE client['device']['type'] NOT ILIKE '%bot%'
When you need to be thorough, build a filter from the is_bot library's
patterns instead — more accurate, and slower:
from is_bot._patterns import default_patterns
conditions = [
f"NOT REGEXP_MATCHES(client['device']['type'], '{regex}', 'i')"
for regex in default_patterns
if "(?" not in regex # DuckDB has no lookahead
]
precise_bot_filter = " AND ".join(conditions)
Count distinct users, not events
COUNT(DISTINCT client['id']) AS event_count -- yes
COUNT(*) AS event_count -- no: one replaying user inflates this
Raw event counts reward repeat plays by the same person, which is rarely the popularity you meant to measure.
Ranking patterns
Top N overall
import time
cutoff_ms = int(time.time() * 1000) - 24 * 60 * 60 * 1000
rows = execute_query(f"""
SELECT event['metadata']['id'] AS content_id,
event['props']['pageType'] AS model_type,
COUNT(DISTINCT client['id']) AS event_count
FROM {parquet}
WHERE collect_timestamp >= {cutoff_ms}
AND client['device']['type'] NOT ILIKE '%bot%'
GROUP BY content_id, model_type
ORDER BY event_count DESC
LIMIT 10
""")
Top N per group
ROW_NUMBER() with PARTITION BY gives you a separate ranking per content type,
category or region in one pass:
WITH events AS (
SELECT event['metadata']['id'] AS content_id,
event['props']['pageType'] AS model_type,
COUNT(DISTINCT client['id']) AS event_count
FROM {parquet}
WHERE client['device']['type'] NOT ILIKE '%bot%'
GROUP BY content_id, model_type
)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY model_type ORDER BY event_count DESC
) AS rank
FROM events
) WHERE rank <= 20
Swap the PARTITION BY for category or region to slice it differently. For
regions, SUBSTRING up to the :: gives you the continent on its own.
Turning rows into JSON
execute_query returns tuples, so name the columns once and zip them:
fields = ["content_id", "model_type", "event_count"]
data = [dict(zip(fields, row)) for row in rows]
That list is what you write to Redis for an endpoint to serve — see Build your first feature for the shape of the whole thing.
Related
- Build your first feature — where a query like this fits in a task.
- General guidelines — the event format these fields come from.
- Redis & Redis Insight — storing the result for fast serving.
peach-templates— runnable notebooks for these patterns, and more underhow-to/.