Skip to content

Commit ab8e67b

Browse files
feat(analytics): server-side Plausible tracking for og:image requests (#3174)
## Summary - Add server-side Plausible tracking for og:image requests from social media bots - Bots (Twitter, WhatsApp, Teams, etc.) don't execute JavaScript, so server-side tracking is required - Fire-and-forget pattern ensures no response delay for image generation ## Changes - **`api/analytics.py`** (NEW): Server-side Plausible tracking module - `track_og_image()` with fire-and-forget pattern - `detect_platform()` for 25 platforms (social, messaging, search, link preview) - **`api/routers/og_images.py`**: Add tracking + new endpoints - `/og/home.png` with filter param tracking - `/og/catalog.png` for catalog page - Tracking added to existing spec/impl endpoints - **`api/routers/seo.py`**: Route og:images through API - `DEFAULT_HOME_IMAGE` → `https://api.pyplots.ai/og/home.png` - `DEFAULT_CATALOG_IMAGE` → `https://api.pyplots.ai/og/catalog.png` - **`docs/architecture/plausible.md`**: Document new event and properties ## New Plausible Event | Event | Properties | |-------|------------| | `og_image_view` | `page`, `platform`, `spec`?, `library`?, `filter_*`? | ## Test plan - [x] All 752 unit tests pass - [x] Ruff lint and format checks pass - [ ] Deploy and verify tracking in Plausible dashboard - [ ] Register new properties in Plausible: `platform`, `filter_lib`, `filter_dom`, etc. - [ ] Create goal: `og_image_view` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cf85628 commit ab8e67b

File tree

6 files changed

+565
-26
lines changed

6 files changed

+565
-26
lines changed

api/analytics.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Server-side Plausible Analytics for og:image tracking.
2+
3+
Tracks og:image requests from social media bots (Twitter, WhatsApp, etc.)
4+
since bots don't execute JavaScript and can't be tracked client-side.
5+
6+
Uses fire-and-forget pattern to avoid delaying responses.
7+
"""
8+
9+
import asyncio
10+
import logging
11+
12+
import httpx
13+
from fastapi import Request
14+
15+
16+
logger = logging.getLogger(__name__)
17+
18+
PLAUSIBLE_ENDPOINT = "https://plausible.io/api/event"
19+
DOMAIN = "pyplots.ai"
20+
21+
# All platforms from nginx.conf bot detection (27 total)
22+
PLATFORM_PATTERNS = {
23+
# Social Media
24+
"twitter": "twitterbot",
25+
"facebook": "facebookexternalhit",
26+
"linkedin": "linkedinbot",
27+
"pinterest": "pinterestbot",
28+
"reddit": "redditbot",
29+
"tumblr": "tumblr",
30+
"mastodon": "mastodon",
31+
# Messaging Apps
32+
"slack": "slackbot",
33+
"discord": "discordbot",
34+
"telegram": "telegrambot",
35+
"whatsapp": "whatsapp",
36+
"signal": "signal",
37+
"viber": "viber",
38+
"skype": "skypeuripreview",
39+
"teams": "microsoft teams",
40+
"snapchat": "snapchat",
41+
# Search Engines
42+
"google": "googlebot",
43+
"bing": "bingbot",
44+
"yandex": "yandexbot",
45+
"duckduckgo": "duckduckbot",
46+
"baidu": "baiduspider",
47+
"apple": "applebot",
48+
# Link Preview Services
49+
"embedly": "embedly",
50+
"quora": "quora link preview",
51+
"outbrain": "outbrain",
52+
"rogerbot": "rogerbot",
53+
"showyoubot": "showyoubot",
54+
}
55+
56+
57+
def detect_platform(user_agent: str) -> str:
58+
"""Detect platform from User-Agent string.
59+
60+
Args:
61+
user_agent: The User-Agent header value
62+
63+
Returns:
64+
Platform name (e.g., 'twitter', 'whatsapp') or 'unknown'
65+
"""
66+
ua_lower = user_agent.lower()
67+
for platform, pattern in PLATFORM_PATTERNS.items():
68+
if pattern in ua_lower:
69+
return platform
70+
return "unknown"
71+
72+
73+
async def _send_plausible_event(user_agent: str, client_ip: str, name: str, url: str, props: dict) -> None:
74+
"""Internal: Send event to Plausible (called as background task).
75+
76+
Args:
77+
user_agent: Original User-Agent header
78+
client_ip: Client IP for geolocation
79+
name: Event name
80+
url: Page URL
81+
props: Event properties
82+
"""
83+
try:
84+
async with httpx.AsyncClient(timeout=5.0) as client:
85+
await client.post(
86+
PLAUSIBLE_ENDPOINT,
87+
headers={"User-Agent": user_agent, "X-Forwarded-For": client_ip, "Content-Type": "application/json"},
88+
json={"name": name, "url": url, "domain": DOMAIN, "props": props},
89+
)
90+
except Exception as e:
91+
logger.debug(f"Plausible tracking failed (non-critical): {e}")
92+
93+
94+
def track_og_image(
95+
request: Request,
96+
page: str,
97+
spec: str | None = None,
98+
library: str | None = None,
99+
filters: dict[str, str] | None = None,
100+
) -> None:
101+
"""Track og:image request (fire-and-forget).
102+
103+
Sends event to Plausible in background without blocking response.
104+
105+
Args:
106+
request: FastAPI request for headers
107+
page: Page type ('home', 'catalog', 'spec_overview', 'spec_detail')
108+
spec: Spec ID (optional)
109+
library: Library ID (optional)
110+
filters: Query params for filtered home page (e.g., {'lib': 'plotly', 'dom': 'statistics'})
111+
"""
112+
user_agent = request.headers.get("user-agent", "")
113+
client_ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
114+
platform = detect_platform(user_agent)
115+
116+
# Build URL based on page type
117+
if page == "home":
118+
url = "https://pyplots.ai/"
119+
elif page == "catalog":
120+
url = "https://pyplots.ai/catalog"
121+
elif spec is not None and library:
122+
url = f"https://pyplots.ai/{spec}/{library}"
123+
elif spec is not None:
124+
url = f"https://pyplots.ai/{spec}"
125+
else:
126+
# Fallback: missing spec for a spec-based page
127+
url = "https://pyplots.ai/"
128+
129+
props: dict[str, str] = {"page": page, "platform": platform}
130+
if spec:
131+
props["spec"] = spec
132+
if library:
133+
props["library"] = library
134+
if filters:
135+
# Add each filter as separate prop (e.g., filter_lib, filter_dom)
136+
# This handles comma-separated values like lib=plotly,matplotlib
137+
for key, value in filters.items():
138+
props[f"filter_{key}"] = value
139+
140+
# Fire-and-forget: create task without awaiting
141+
asyncio.create_task(_send_plausible_event(user_agent, client_ip, "og_image_view", url, props))

api/routers/og_images.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,64 @@
11
"""OG Image endpoints for branded social media preview images."""
22

33
import asyncio
4+
from pathlib import Path
45

56
import httpx
6-
from fastapi import APIRouter, Depends, HTTPException
7+
from fastapi import APIRouter, Depends, HTTPException, Request
78
from fastapi.responses import Response
89
from sqlalchemy.ext.asyncio import AsyncSession
910

11+
from api.analytics import track_og_image
1012
from api.cache import cache_key, get_cache, set_cache
1113
from api.dependencies import optional_db
1214
from core.database import SpecRepository
1315
from core.images import create_branded_og_image, create_og_collage
1416

1517

18+
# Static og:image (loaded once at startup)
19+
_STATIC_OG_IMAGE: bytes | None = None
20+
21+
22+
def _get_static_og_image() -> bytes:
23+
"""Load static og-image.png (cached in memory)."""
24+
global _STATIC_OG_IMAGE
25+
if _STATIC_OG_IMAGE is None:
26+
path = Path(__file__).parent.parent.parent / "app" / "public" / "og-image.png"
27+
try:
28+
_STATIC_OG_IMAGE = path.read_bytes()
29+
except FileNotFoundError as exc:
30+
raise HTTPException(status_code=500, detail="Static OG image not found") from exc
31+
return _STATIC_OG_IMAGE
32+
33+
1634
router = APIRouter(prefix="/og", tags=["og-images"])
1735

1836

37+
@router.get("/home.png")
38+
async def get_home_og_image(request: Request) -> Response:
39+
"""OG image for home page with tracking.
40+
41+
Supports filter params (e.g., ?lib=plotly&dom=statistics) for tracking shared filtered URLs.
42+
"""
43+
# Capture filter params for tracking (e.g., ?lib=plotly&dom=statistics)
44+
filters = dict(request.query_params) if request.query_params else None
45+
track_og_image(request, page="home", filters=filters)
46+
47+
return Response(
48+
content=_get_static_og_image(), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}
49+
)
50+
51+
52+
@router.get("/catalog.png")
53+
async def get_catalog_og_image(request: Request) -> Response:
54+
"""OG image for catalog page with tracking."""
55+
track_og_image(request, page="catalog")
56+
57+
return Response(
58+
content=_get_static_og_image(), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}
59+
)
60+
61+
1962
async def _fetch_image(url: str) -> bytes:
2063
"""Fetch an image from a URL."""
2164
async with httpx.AsyncClient(timeout=30.0) as client:
@@ -26,12 +69,15 @@ async def _fetch_image(url: str) -> bytes:
2669

2770
@router.get("/{spec_id}/{library}.png")
2871
async def get_branded_impl_image(
29-
spec_id: str, library: str, db: AsyncSession | None = Depends(optional_db)
72+
spec_id: str, library: str, request: Request, db: AsyncSession | None = Depends(optional_db)
3073
) -> Response:
3174
"""Get a branded OG image for an implementation.
3275
3376
Returns a 1200x630 PNG with pyplots.ai header and the plot image.
3477
"""
78+
# Track og:image request (fire-and-forget)
79+
track_og_image(request, page="spec_detail", spec=spec_id, library=library)
80+
3581
# Check cache first
3682
key = cache_key("og", spec_id, library)
3783
cached = get_cache(key)
@@ -70,12 +116,17 @@ async def get_branded_impl_image(
70116

71117

72118
@router.get("/{spec_id}.png")
73-
async def get_spec_collage_image(spec_id: str, db: AsyncSession | None = Depends(optional_db)) -> Response:
119+
async def get_spec_collage_image(
120+
spec_id: str, request: Request, db: AsyncSession | None = Depends(optional_db)
121+
) -> Response:
74122
"""Get a collage OG image for a spec (showing top 6 implementations by quality).
75123
76124
Returns a 1200x630 PNG with pyplots.ai branding and a 2x3 grid of implementations,
77125
sorted by quality_score descending.
78126
"""
127+
# Track og:image request (fire-and-forget)
128+
track_og_image(request, page="spec_overview", spec=spec_id)
129+
79130
# Check cache first
80131
key = cache_key("og", spec_id, "collage")
81132
cached = get_cache(key)

api/routers/seo.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import html
44

5-
from fastapi import APIRouter, Depends, HTTPException
5+
from fastapi import APIRouter, Depends, HTTPException, Request
66
from fastapi.responses import HTMLResponse, Response
77
from sqlalchemy.ext.asyncio import AsyncSession
88

@@ -36,7 +36,9 @@
3636
<body><h1>{title}</h1><p>{description}</p></body>
3737
</html>"""
3838

39-
DEFAULT_IMAGE = "https://pyplots.ai/og-image.png"
39+
# Route through API for tracking (was: pyplots.ai/og-image.png)
40+
DEFAULT_HOME_IMAGE = "https://api.pyplots.ai/og/home.png"
41+
DEFAULT_CATALOG_IMAGE = "https://api.pyplots.ai/og/catalog.png"
4042
DEFAULT_DESCRIPTION = "library-agnostic, ai-powered python plotting."
4143

4244

@@ -89,12 +91,19 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)):
8991

9092

9193
@router.get("/seo-proxy/")
92-
async def seo_home():
93-
"""Bot-optimized home page with correct og:tags."""
94+
async def seo_home(request: Request):
95+
"""Bot-optimized home page with correct og:tags.
96+
97+
Passes query params (e.g., ?lib=plotly&dom=statistics) to og:image URL for tracking.
98+
"""
99+
# Pass filter params to og:image URL for tracking shared filtered URLs
100+
# Use html.escape to prevent XSS via query params
101+
query_string = html.escape(str(request.query_params), quote=True) if request.query_params else ""
102+
image_url = f"{DEFAULT_HOME_IMAGE}?{query_string}" if query_string else DEFAULT_HOME_IMAGE
103+
page_url = f"https://pyplots.ai/?{query_string}" if query_string else "https://pyplots.ai/"
104+
94105
return HTMLResponse(
95-
BOT_HTML_TEMPLATE.format(
96-
title="pyplots.ai", description=DEFAULT_DESCRIPTION, image=DEFAULT_IMAGE, url="https://pyplots.ai/"
97-
)
106+
BOT_HTML_TEMPLATE.format(title="pyplots.ai", description=DEFAULT_DESCRIPTION, image=image_url, url=page_url)
98107
)
99108

100109

@@ -105,7 +114,7 @@ async def seo_catalog():
105114
BOT_HTML_TEMPLATE.format(
106115
title="Catalog | pyplots.ai",
107116
description="Browse all Python plotting specifications alphabetically. Find matplotlib, seaborn, plotly, bokeh, altair examples.",
108-
image=DEFAULT_IMAGE,
117+
image=DEFAULT_CATALOG_IMAGE,
109118
url="https://pyplots.ai/catalog",
110119
)
111120
)
@@ -120,7 +129,7 @@ async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(opti
120129
BOT_HTML_TEMPLATE.format(
121130
title=f"{spec_id} | pyplots.ai",
122131
description=DEFAULT_DESCRIPTION,
123-
image=DEFAULT_IMAGE,
132+
image=DEFAULT_HOME_IMAGE,
124133
url=f"https://pyplots.ai/{html.escape(spec_id)}",
125134
)
126135
)
@@ -132,7 +141,7 @@ async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(opti
132141

133142
# Use collage og:image if implementations exist, otherwise default
134143
has_previews = any(i.preview_url for i in spec.impls)
135-
image = f"https://api.pyplots.ai/og/{spec_id}.png" if has_previews else DEFAULT_IMAGE
144+
image = f"https://api.pyplots.ai/og/{spec_id}.png" if has_previews else DEFAULT_HOME_IMAGE
136145

137146
return HTMLResponse(
138147
BOT_HTML_TEMPLATE.format(
@@ -153,7 +162,7 @@ async def seo_spec_implementation(spec_id: str, library: str, db: AsyncSession |
153162
BOT_HTML_TEMPLATE.format(
154163
title=f"{html.escape(spec_id)} - {html.escape(library)} | pyplots.ai",
155164
description=DEFAULT_DESCRIPTION,
156-
image=DEFAULT_IMAGE,
165+
image=DEFAULT_HOME_IMAGE,
157166
url=f"https://pyplots.ai/{html.escape(spec_id)}/{html.escape(library)}",
158167
)
159168
)
@@ -166,7 +175,7 @@ async def seo_spec_implementation(spec_id: str, library: str, db: AsyncSession |
166175
# Find the implementation for this library
167176
impl = next((i for i in spec.impls if i.library_id == library), None)
168177
# Use branded og:image endpoint if implementation has preview
169-
image = f"https://api.pyplots.ai/og/{spec_id}/{library}.png" if impl and impl.preview_url else DEFAULT_IMAGE
178+
image = f"https://api.pyplots.ai/og/{spec_id}/{library}.png" if impl and impl.preview_url else DEFAULT_HOME_IMAGE
170179

171180
return HTMLResponse(
172181
BOT_HTML_TEMPLATE.format(

0 commit comments

Comments
 (0)