Most content teams make editorial decisions based on page views, time-on-page, and bounce rate — metrics that tell you something happened but not why or what to do about it. AIKit's EmDash CMS solves this with plugin-level analytics that tracks exactly how readers interact with individual content features, giving you actionable data instead of vanity metrics.
The Problem: Content Metrics That Don't Tell You What to Do
Page views measure one thing: that someone loaded your page. They don't tell you whether the reader scrolled, clicked anything useful, or left frustrated. Time-on-page is slightly better but easily inflated by readers who open a tab and walk away. Bounce rate tells you someone left — but not why or where they might have gone instead.
For content strategists making editorial decisions — which topics to commission, which formats to invest in, where to place calls to action — these traditional metrics are dangerously ambiguous. A high bounce rate could mean your headline was misleading, your content was boring, or your reader got the answer they needed in the first paragraph and left satisfied. You simply can't tell the difference.
Consider a typical content team evaluating two post formats: a deep architectural deep-dive and a short tutorial. Page views might favor the tutorial, but time-on-page favors the architecture piece. Which one should you write more of? Without knowing what readers actually did inside each post — did they use the table of contents? Did they copy code blocks? Did they click through to related posts? — you're making bets, not decisions.
The Solution: Plugin-Level Engagement Analytics
EmDash's plugin architecture was designed so that every content feature is a self-contained plugin with its own lifecycle, rendering, and storage. Tables of contents, syntax-highlighted code blocks with copy buttons, related post widgets, social share buttons — each one can independently track how readers interact with it.
This means the table of contents plugin knows which headings were clicked and in what order. The code block plugin knows when a reader copied a snippet. The related posts plugin knows which suggestions drew a click. These aren't inferred metrics — they're direct observations of reader behavior at the feature level.
```python
Example: A plugin analytics event structure
{
"event_id": "evt_8a3f2c1b",
"plugin_id": "toc_navigation",
"post_slug": "building-real-time-pipelines",
"session_id": "sess_9d8e7f6a",
"timestamp": "2026-05-20T14:32:11Z",
"action": "heading_click",
"metadata": {
"heading_text": "Architecture Overview",
"heading_level": 2,
"position_in_toc": 1
}
}
```
Plugin-level data tells you *what*, *where*, and *in what context* an interaction happened. You know they spent 90 seconds on the architecture section, clicked two code blocks, and navigated to a related tutorial — a complete behavioral story.
Architecture: The Analytics Pipeline
The pipeline uses three Cloudflare primitives:
| Component | Purpose | Data | TTL / Frequency |
|-----------|---------|------|-----------------|
| Cloudflare D1 | Event store | Raw per-plugin interaction events | Permanent |
| Cloudflare KV | Cache layer | Aggregated metrics for dashboard and reports | 5 min (dashboard), 1 hour (reports) |
| Workers Cron | Aggregation engine | Runs batch aggregation queries | Every 15 minutes |
Raw events flow from the EmDash frontend directly into D1 as individual rows — a few hundred bytes each, costing a fraction of a cent to store. The expensive work (aggregation, filtering, joining) is deferred to the cron-driven Workers layer.
```sql
-- D1 schema for plugin analytics events
CREATE TABLE plugin_events (
event_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
post_slug TEXT NOT NULL,
session_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
action TEXT NOT NULL,
metadata_json TEXT
);
CREATE INDEX idx_plugin_events_post ON plugin_events(post_slug, timestamp);
CREATE INDEX idx_plugin_events_plugin ON plugin_events(plugin_id, timestamp);
```
The aggregation Worker runs as a cron trigger every 15 minutes. It queries D1 for new events since the last run, computes counts and rates per post per plugin, then writes results to KV with a 5-minute TTL for the dashboard and a 1-hour TTL for reports. The database stays lean because aggregation prevents raw event scanning on every page load.
```python
async def run_aggregation(env):
last_run = await env.KV.get("analytics:last_aggregation", type="integer")
if not last_run:
last_run = int(time.time()) - 900
events = await env.DB.prepare(
"SELECT plugin_id, post_slug, action, COUNT(*) as count "
"FROM plugin_events WHERE timestamp > ? "
"GROUP BY plugin_id, post_slug, action"
).bind(last_run).all()
for row in events:
key = f"analytics:{row['plugin_id']}:{row['post_slug']}:{row['action']}"
await env.KV.put(key, json.dumps({
"count": row["count"],
"period_start": last_run,
"period_end": int(time.time())
}))
await env.KV.put("analytics:last_aggregation", str(int(time.time())))
```
Implementation: Adding Plugin Analytics to EmDash
Every EmDash plugin has access to `_plugin_storage`, a namespaced key-value store scoped to the plugin instance. This means each installation gets its own isolated storage — no collisions, no cross-contamination.
To add analytics, use `_plugin_storage.set()` in the plugin's lifecycle hooks. Here's a concrete example for two common interactions:
```python
class CodeBlockPlugin:
def __init__(self, storage):
self.storage = storage
async def on_copy(self, request, code_content, language):
event = {
"event_type": "code_copy",
"language": language,
"code_length": len(code_content),
"timestamp": int(time.time()),
"post_slug": request.url.path.split("/")[-1]
}
await self.storage.set(
f"events:code_copy:{int(time.time())}", json.dumps(event))
class TableOfContentsPlugin:
def __init__(self, storage):
self.storage = storage
async def on_heading_click(self, request, heading_id, heading_text, depth):
event = {
"event_type": "toc_click",
"heading_id": heading_id,
"heading_text": heading_text,
"depth": depth,
"timestamp": int(time.time()),
"post_slug": request.url.path.split("/")[-1]
}
await self.storage.set(
f"events:toc_click:{int(time.time())}", json.dumps(event))
```
Because D1 uses a SQLite-compatible interface, aggregation queries use familiar patterns. The storage bridge flushes events to D1 in batches, so hooks don't need to manage database connections.
Results: Content Decisions Powered by Data
Within the first month, several actionable insights emerged:
**Post format retention.** Tutorials and architecture posts attracted similar page views, but plugin analytics showed that architecture posts had a 2.3x higher deep-engagement rate — readers clicked more table of contents entries, spent more time in lower sections, and copied code blocks at twice the rate. The editorial team increased architecture deep-dives from 30% to 50% of the publishing calendar.
**CTA effectiveness.** The related-posts plugin showed that inline CTAs placed after the third section heading had 4.1x higher click-through rates than those at the bottom of the page. Moving CTAs earlier in every post drove a 34% increase in internal navigation.
**Content length and interaction rate.** Posts between 1500 and 2500 words had the highest interaction density — enough depth to warrant navigation features like the TOC, but not so long that readers skimmed without engaging. This directly informed a new editorial guideline.
Key Takeaways
Plugin-level analytics transforms content strategy from instinct to science. Instead of asking "did people like it?" you ask "what did people do with it?" — and you get a precise answer backed by real interaction data at feature granularity.
The technical overhead is minimal. D1 stores raw events at sub-cent cost per thousand writes, KV caches aggregation outputs for instant dashboard queries, and a Workers cron job ties it together with a few dozen lines of code. The entire pipeline costs less than a streaming analytics SaaS product.
Start with three events per plugin: one for the primary interaction, one for hover or popover engagement, and one for dismissal. Track those for two weeks, then expand based on what you learn. The EmDash plugin architecture makes it trivial to add new event types — you're just adding a hook call and a storage write. The hard part isn't the code; it's deciding which questions to ask. Plugin analytics gives you the answers.