Apache Flagon Distill 2.5+ includes dedicated behavioral analytics modules (distill.analytics.frustration, distill.analytics.spatial, and distill.analytics.otel) for quantifying UX friction, mapping spatial click/scroll density, and correlating frontend user telemetry with distributed OpenTelemetry traces.
All behavioral analytics functions accept:
LogBatch dictionary ({"logs": [...]}),ExportLogsServiceRequest dictionary ({"resourceLogs": [...]}), ordistill.setup(...)).distill.analytics.frustration)detect_rage_clicks)Detects rapid, repeated clicks on the same DOM target within a short time and spatial window. Supports both:
rage_click custom logs emitted in real time by UserALE 2.5’s RageClickDetector (source: "explicit"), andclick event streams (source: "inferred").import distill
incidents = distill.detect_rage_clicks(
logs,
window_ms=1000, # Sliding window in milliseconds (default: 1000)
threshold=3, # Minimum clicks within window_ms (default: 3)
radius_px=30.0, # Maximum Euclidean distance in pixels (default: 30.0)
)
Each returned incident dictionary contains:
{
"target": "button#checkout-submit",
"pageUrl": "https://shop.example.com/cart",
"sessionId": "session_1712000000",
"timestamp": 1712000045120,
"clickCount": 4,
"targetRect": {"x": 420, "y": 510, "width": 160, "height": 44},
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"source": "explicit", # or "inferred"
}
detect_dead_clicks)Identifies clicks on elements that produce no observable application response within response_window_ms — i.e., no subsequent DOM mutation (logType == "mutation"), no form/input event (input, change, submit), and no navigation (load or pageUrl change).
dead_clicks = distill.detect_dead_clicks(
logs,
response_window_ms=1000,
)
detect_dom_thrash)Analyzes UserALE mutation logs emitted by MutationCollector to identify high-churn layout bursts (addedCount + removedCount + attributeCount >= churn_threshold) and rank DOM targets by cumulative structural churn.
thrash = distill.detect_dom_thrash(logs, churn_threshold=20)
print(thrash["totalChurn"], thrash["burstCount"])
print(thrash["targetChurn"]) # {"div#product-grid": 64, "ul#search-results": 22}
frustration_score)Computes a normalized [0.0, 100.0] UX friction score and severity rating ("none", "low", "medium", "high") combining rage clicks, dead clicks, DOM thrash bursts, and runtime JS errors (error / unhandledrejection):
report = distill.frustration_score(logs)
# {
# "frustrationScore": 65.0,
# "severity": "high",
# "rageClickCount": 2,
# "deadClickCount": 1,
# "domThrashBurstCount": 1,
# "errorCount": 0,
# "rageClicks": [...],
# "deadClicks": [...],
# }
distill.analytics.spatial)click_heatmap_data)Extracts raw (x, y) click coordinates alongside viewport-normalized coordinates (normX, normY in [0.0, 1.0] using scrnRes) and element-relative coordinates (relX, relY in [0.0, 1.0] when targetRect bounding boxes are present):
points = distill.click_heatmap_data(logs, normalize_viewport=True)
# [
# {
# "target": "button#buy-now",
# "pageUrl": "https://shop.example.com/item/42",
# "type": "click",
# "x": 640.0, "y": 360.0,
# "normX": 0.5, "normY": 0.5,
# "relX": 0.42, "relY": 0.55,
# "targetRect": {"x": 580, "y": 340, "width": 140, "height": 36}
# }
# ]
plot_click_heatmap)Generates a Plotly Figure (go.Histogram2d with top-down screen y-axis orientation) for rendering in Jupyter notebooks or web dashboards:
fig = distill.plot_click_heatmap(
logs,
nbinsx=25,
nbinsy=25,
normalize_viewport=True,
)
fig.show()
scroll_depth_summary)Aggregates scroll and wheel telemetry per pageUrl to calculate maximum vertical/horizontal scroll reach and how many viewport heights (maxViewportMultiple) users scrolled down each page:
scroll_metrics = distill.scroll_depth_summary(logs)
# {
# "https://shop.example.com/catalog": {
# "maxScrollX": 0.0,
# "maxScrollY": 2160.0,
# "scrollEventCount": 18,
# "maxViewportMultiple": 3.0
# }
# }
distill.analytics.otel)traceId (correlate_by_trace)Groups all frontend UserALE interactions, rage clicks, DOM mutations, and session replay envelopes by their W3C TraceContext traceId (flagon.trace_id in OTLP):
traces = distill.correlate_by_trace(otlp_or_userale_payload)
for trace_id, info in traces.items():
print(trace_id, info["targets"], info["rageClickCount"], info["spanIds"])
extract_replay_envelopes)Extracts Layer 2 rrweb-compatible DOM replay envelopes (logType == "replay") triggered by rage clicks, uncaught exceptions, or manual flushes:
envelopes = distill.extract_replay_envelopes(logs)
for env in envelopes:
print(env["trigger"], env["sessionId"], env["frameCount"], env["traceId"])