Skip to content

Commit ab468e6

Browse files
committed
plain-connect: add pageview tracking via {% connect_pageviews %}
Adds anonymous pageview analytics with optional signed-in user attribution, injected with the {% connect_pageviews %} template tag and reported to the pageview beacon endpoint. The identity token encrypts the authenticated user's id (AES-256-GCM) so the raw id never appears in page HTML; the user is read via plain.auth.get_request_user(), with a lazy import so apps without plain.auth still work. Includes a test suite covering identity encryption and the rendered tag. Also renames the stale PLAIN_CLOUD_EXPORT_ENABLED env var to PLAIN_CONNECT_EXPORT_ENABLED.
1 parent 64ee8a4 commit ab468e6

19 files changed

Lines changed: 408 additions & 16 deletions

File tree

example/.env.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ DEV_TUNNEL_URL=https://plain-example.plaintunnel.com
77
# Keep tests from exporting telemetry to the real endpoint, even when
88
# someone runs pytest directly against the example app without going
99
# through scripts/test.
10-
PLAIN_CLOUD_EXPORT_ENABLED=false
10+
PLAIN_CONNECT_EXPORT_ENABLED=false

plain-connect/plain/connect/README.md

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Settings](#settings)
77
- [Sampling](#sampling)
88
- [What gets exported](#what-gets-exported)
9+
- [Pageview tracking](#pageview-tracking)
910
- [Observer coexistence](#observer-coexistence)
1011
- [FAQs](#faqs)
1112
- [Installation](#installation)
@@ -24,13 +25,16 @@ If `CONNECT_EXPORT_TOKEN` is not set, the package is a no-op — safe to install
2425

2526
## Settings
2627

27-
| Setting | Default | Description |
28-
| --------------------------- | ------------------------------------- | ----------------------------------------------------------- |
29-
| `CONNECT_EXPORT_URL` | `"https://ingest.plainframework.com"` | OTLP ingest endpoint (override to use a custom endpoint) |
30-
| `CONNECT_EXPORT_TOKEN` | `""` | Auth token for the export endpoint |
31-
| `CONNECT_TRACE_SAMPLE_RATE` | `1.0` | Probability of exporting a trace (0.0–1.0) |
32-
| `CONNECT_EXPORT_LOGS` | `True` | Set to `False` to disable OTLP log export |
33-
| `CONNECT_LOG_LEVEL` | `"INFO"` | Minimum severity exported via OTLP logs (level name or int) |
28+
| Setting | Default | Description |
29+
| -------------------------------- | ------------------------------------- | ------------------------------------------------------------------------- |
30+
| `CONNECT_EXPORT_URL` | `"https://ingest.plainframework.com"` | OTLP ingest endpoint (override to use a custom endpoint) |
31+
| `CONNECT_EXPORT_TOKEN` | `""` | Auth token for the export endpoint |
32+
| `CONNECT_TRACE_SAMPLE_RATE` | `1.0` | Probability of exporting a trace (0.0–1.0) |
33+
| `CONNECT_EXPORT_LOGS` | `True` | Set to `False` to disable OTLP log export |
34+
| `CONNECT_LOG_LEVEL` | `"INFO"` | Minimum severity exported via OTLP logs (level name or int) |
35+
| `CONNECT_PAGEVIEWS_TOKEN` | `""` | Public pageview-endpoint token; enables the `{% connect_pageviews %}` tag |
36+
| `CONNECT_PAGEVIEWS_IDENTITY_KEY` | `""` | Secret key for encrypting the logged-in user id into the identity token |
37+
| `CONNECT_PAGEVIEWS_URL` | `"https://beacon.plainframework.com"` | Pageview ingest endpoint |
3438

3539
All settings can be set via `PLAIN_`-prefixed environment variables or in `app/settings.py`.
3640

@@ -52,6 +56,34 @@ Metrics are not affected by sampling — histograms aggregate in-process and exp
5256

5357
**Logs** — Records from the `plain` and `app` loggers, plus anything propagating to the root logger, are bridged into OTLP log records and exported with `trace_id` / `span_id` set from the active span. The minimum severity is controlled by `CONNECT_LOG_LEVEL` (default `INFO`); the root logger's level is widened to that floor when needed so libraries using `getLogger(__name__)` reach the exporter. To prevent feedback loops, two sources are skipped on the export path: the `opentelemetry` namespace, and any record emitted from inside the OTLP exporter's background thread (e.g. urllib3 connection errors raised by the exporter's own HTTP call). Your application's urllib3 logs are exported normally.
5458

59+
## Pageview tracking
60+
61+
plain.connect can track page views in your app — anonymous page-view analytics plus, optionally, attribution to your signed-in users. This is independent of the OTLP export above.
62+
63+
Add the tag to your base template, just before `</body>`:
64+
65+
```html
66+
{% connect_pageviews %}
67+
```
68+
69+
Then set the public endpoint token:
70+
71+
```
72+
PLAIN_CONNECT_PAGEVIEWS_TOKEN=plain_pv_...
73+
```
74+
75+
The tag renders nothing until the token is set. Once enabled, it reports the URL, title, referrer, and a first-party anonymous id on each page load and SPA navigation (History `pushState` / back-forward).
76+
77+
### User attribution
78+
79+
To attribute pageviews to your signed-in users, also set the secret identity key:
80+
81+
```
82+
PLAIN_CONNECT_PAGEVIEWS_IDENTITY_KEY=...
83+
```
84+
85+
When set, the tag encrypts the signed-in user's id (AES-256-GCM) into an opaque token that only the pageview endpoint can decrypt — the raw id never appears in your HTML. The user is read from [plain.auth](../../plain-auth/plain/auth/README.md); apps without it (or anonymous visitors) are still counted, their pageviews simply carry no user id.
86+
5587
## Observer coexistence
5688

5789
If [plain.observer](../../plain-observer/plain/observer/README.md) is also installed, both work simultaneously. plain.connect handles production export while observer provides the local dev toolbar and admin trace viewer. Observer detects the existing TracerProvider and layers its sampler and span processor on top.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
(() => {
2+
const script = document.currentScript;
3+
if (!script) return;
4+
5+
// Guard against double-injection — patching pushState twice would
6+
// multiply every navigation event.
7+
if (window.__plainPageviews) return;
8+
window.__plainPageviews = true;
9+
10+
const token = script.dataset.token;
11+
const pageviewsUrl = script.dataset.pageviewsUrl;
12+
if (!token || !pageviewsUrl) return;
13+
14+
const identity = script.dataset.identity || "";
15+
const initialTraceId = script.dataset.traceId || "";
16+
17+
const ANONYMOUS_ID_KEY = "plain_pageviews_anonymous_id";
18+
19+
function anonymousId() {
20+
try {
21+
let id = localStorage.getItem(ANONYMOUS_ID_KEY);
22+
if (!id) {
23+
id = crypto.randomUUID();
24+
localStorage.setItem(ANONYMOUS_ID_KEY, id);
25+
}
26+
return id;
27+
} catch {
28+
// localStorage unavailable (private mode, blocked storage, etc.)
29+
return "";
30+
}
31+
}
32+
33+
const anonId = anonymousId();
34+
let isInitialView = true;
35+
let lastUrl = "";
36+
37+
function send() {
38+
if (location.href === lastUrl) return;
39+
40+
const payload = {
41+
token,
42+
url: location.href,
43+
title: document.title,
44+
referrer: isInitialView ? document.referrer : lastUrl,
45+
anonymous_id: anonId,
46+
identity,
47+
// Only the server-rendered initial load has a backend trace to link to.
48+
trace_id: isInitialView ? initialTraceId : "",
49+
};
50+
51+
lastUrl = location.href;
52+
isInitialView = false;
53+
54+
try {
55+
navigator.sendBeacon(pageviewsUrl, JSON.stringify(payload));
56+
} catch {
57+
// sendBeacon unsupported or blocked — drop the event.
58+
}
59+
}
60+
61+
send();
62+
63+
// SPA navigations: History pushState + back/forward.
64+
const originalPushState = history.pushState;
65+
history.pushState = function (...args) {
66+
const result = originalPushState.apply(this, args);
67+
setTimeout(send, 0);
68+
return result;
69+
};
70+
window.addEventListener("popstate", () => setTimeout(send, 0));
71+
})();

plain-connect/plain/connect/default_settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,11 @@
88
# Minimum severity exported via OTLP logs. Accepts a level name ("INFO",
99
# "DEBUG", ...) or the integer level value.
1010
CONNECT_LOG_LEVEL: str = "INFO"
11+
12+
# Pageview tracking — injected via the {% connect_pageviews %} template tag.
13+
# Public endpoint token; safe to expose in page HTML.
14+
CONNECT_PAGEVIEWS_TOKEN: str = ""
15+
# Secret key for encrypting the logged-in user id into the identity token.
16+
CONNECT_PAGEVIEWS_IDENTITY_KEY: Secret[str] = ""
17+
# Pageview ingest endpoint.
18+
CONNECT_PAGEVIEWS_URL: str = "https://beacon.plainframework.com"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import hashlib
5+
import os
6+
7+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8+
9+
_NONCE_BYTES = 12
10+
11+
12+
def _derive_key(identity_key: str) -> bytes:
13+
"""Derive a 32-byte AES-256 key from the endpoint's identity_key.
14+
15+
A plain SHA-256 is sufficient (no salt/HKDF) because identity_key is a
16+
high-entropy server-generated secret, not a user password. The beacon
17+
Worker's decrypt must derive the key identically.
18+
"""
19+
return hashlib.sha256(identity_key.encode()).digest()
20+
21+
22+
def encrypt_identity(user_id: int | str, identity_key: str) -> str:
23+
"""Encrypt a user id into an opaque token for the pageview beacon.
24+
25+
AES-256-GCM with a random nonce. The beacon endpoint decrypts it with the
26+
same identity_key, so the raw user id never appears in page HTML. Returns
27+
base64url(nonce + ciphertext + GCM tag).
28+
"""
29+
key = _derive_key(identity_key)
30+
nonce = os.urandom(_NONCE_BYTES)
31+
ciphertext = AESGCM(key).encrypt(nonce, str(user_id).encode(), None)
32+
return base64.urlsafe_b64encode(nonce + ciphertext).decode()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from jinja2.runtime import Context
6+
from opentelemetry import trace
7+
8+
from plain.runtime import settings
9+
from plain.templates import register_template_extension
10+
from plain.templates.jinja.extensions import InclusionTagExtension
11+
12+
from .identity import encrypt_identity
13+
14+
15+
@register_template_extension
16+
class ConnectPageviewsExtension(InclusionTagExtension):
17+
tags = {"connect_pageviews"}
18+
template_name = "connect/pageviews.html"
19+
20+
def get_context(
21+
self, context: Context, *args: Any, **kwargs: Any
22+
) -> dict[str, Any]:
23+
request = context.get("request")
24+
token = settings.CONNECT_PAGEVIEWS_TOKEN
25+
return {
26+
"request": request,
27+
"connect_pageviews_token": token,
28+
"connect_pageviews_url": settings.CONNECT_PAGEVIEWS_URL,
29+
"connect_pageviews_identity": _identity_token(request) if token else "",
30+
"connect_pageviews_trace_id": _current_trace_id() if token else "",
31+
}
32+
33+
34+
def _identity_token(request: Any) -> str:
35+
identity_key = str(settings.CONNECT_PAGEVIEWS_IDENTITY_KEY)
36+
if not identity_key or request is None:
37+
return ""
38+
39+
# Plain stores the authenticated user off-request (keyed by the request
40+
# object), reachable only via plain.auth. plain.connect doesn't depend on
41+
# plain.auth, so an app without it simply has no identity to attribute.
42+
try:
43+
from plain.auth import get_request_user
44+
except ImportError:
45+
return ""
46+
47+
user = get_request_user(request)
48+
if user is None:
49+
return ""
50+
return encrypt_identity(user.id, identity_key)
51+
52+
53+
def _current_trace_id() -> str:
54+
span_context = trace.get_current_span().get_span_context()
55+
if not span_context.trace_id:
56+
return ""
57+
return format(span_context.trace_id, "032x")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{% if connect_pageviews_token %}<script src="{{ asset('connect/pageviews.js') }}" data-token="{{ connect_pageviews_token }}" data-pageviews-url="{{ connect_pageviews_url }}" data-identity="{{ connect_pageviews_identity }}" data-trace-id="{{ connect_pageviews_trace_id }}" async nonce="{{ request.csp_nonce }}"></script>{% endif %}

plain-connect/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ dependencies = [
1010
"plain>=0.113.0,<1.0.0",
1111
"opentelemetry-sdk>=1.34.1",
1212
"opentelemetry-exporter-otlp-proto-http>=1.34.1",
13+
"cryptography>=42.0.0",
1314
]
1415

1516
[dependency-groups]
16-
dev = ["plain.pytest<1.0.0"]
17+
dev = ["plain.pytest<1.0.0", "plain.auth<1.0.0", "plain.assets<1.0.0", "plain.templates<1.0.0"]
1718

1819
[tool.hatch.build.targets.wheel]
1920
packages = ["plain"]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
SECRET_KEY = "test"
2+
URLS_ROUTER = "app.urls.AppRouter"
3+
INSTALLED_PACKAGES = [
4+
"plain.assets",
5+
"plain.templates",
6+
"plain.sessions",
7+
"plain.auth",
8+
"plain.postgres",
9+
"plain.connect",
10+
"app.users",
11+
]
12+
MIDDLEWARE = [
13+
"plain.sessions.middleware.SessionMiddleware",
14+
"plain.auth.middleware.AuthMiddleware",
15+
]
16+
AUTH_LOGIN_URL = "page"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!doctype html>
2+
<html>
3+
<body>
4+
{% connect_pageviews %}
5+
</body>
6+
</html>

0 commit comments

Comments
 (0)