better tracking
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
## Context
|
||||
|
||||
The site uses Umami custom events via data attributes on clickables (e.g., navigation, CTAs, outbound links). Today, most tracked links include stable identifiers like `target_id`, `placement`, and (for links) `target_url`.
|
||||
|
||||
This is sufficient to measure *where* users clicked, but it is limited for content discovery because it does not capture content metadata (e.g., which specific video/post title was clicked). Umami supports adding additional event data via `data-umami-event-*` attributes, which are recorded as strings.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Add content metadata fields to Umami click tracking for content-related links:
|
||||
- `title` (human-readable title)
|
||||
- `type` (content type)
|
||||
- Apply consistently across content surfaces (videos, podcast, blog).
|
||||
- Keep existing taxonomy constraints intact:
|
||||
- stable deterministic `target_id`
|
||||
- `placement`
|
||||
- `target_url` for links
|
||||
- Avoid tracking PII.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Introducing JavaScript-based `window.umami.track` calls (continue using Umami data-attribute tracking).
|
||||
- Tracking clicks inside arbitrary WordPress-rendered HTML bodies (future enhancement if needed).
|
||||
- Changing Umami initialization or environment configuration.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Decision: Use Option 1 (separate `title` and `type` fields).**
|
||||
- Rationale: Makes reporting and filtering easier (segment by `type`, then list top `title`). Avoids parsing concatenated strings in analytics.
|
||||
- Alternative: Option 2 (single `title` field formatted as `[type]-[title]`). Rejected for reduced queryability.
|
||||
|
||||
- **Decision: Only apply `title`/`type` to content-related links (not all links).**
|
||||
- Rationale: Many links do not map cleanly to a single content item (e.g., category nav, pagination, generic navigation).
|
||||
|
||||
- **Decision: Normalize type values.**
|
||||
- Rationale: Stable `type` values enable dashboards to be reused over time.
|
||||
- Proposed set (from specs): `video`, `podcast_episode`, `blog_post`, `blog_page`.
|
||||
|
||||
- **Decision: Prefer shared components to propagate tracking fields.**
|
||||
- Rationale: Centralize logic and reduce missed clickables.
|
||||
- Approach:
|
||||
- Extend existing link/card components (where applicable) to accept optional `umamiTitle` and `umamiType` props.
|
||||
- For pages that render raw `<a>` tags directly, add attributes inline.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Title values can change over time (content edits) which may reduce longitudinal stability.
|
||||
- Mitigation: Keep `target_id` deterministic and stable; use `title` for reporting convenience only.
|
||||
|
||||
- [Risk] Very long titles.
|
||||
- Mitigation: Truncate `title` values to a reasonable length (e.g., 120-160 chars) at instrumentation time if needed.
|
||||
|
||||
- [Risk] Inconsistent application across surfaces.
|
||||
- Mitigation: Add tests that assert content clickables include `data-umami-event-title` and `data-umami-event-type` where applicable.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
Umami click tracking is currently limited to `target_id`/`placement`, which makes it harder to understand *which* specific content items (by title/type) users engage with most. Adding lightweight content metadata to click events enables clearer measurement and reporting.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Extend Umami click event instrumentation so content-related links include additional event data:
|
||||
- `data-umami-event-title`: the content title (e.g., post/video/episode/page title)
|
||||
- `data-umami-event-type`: the content type (e.g., `blog_post`, `blog_page`, `video`, `podcast_episode`)
|
||||
- Apply the above consistently across all instrumented content links (cards, lists, navigation items that represent a specific piece of content).
|
||||
- Ensure the metadata is additive and does not replace the existing deterministic identifiers:
|
||||
- keep `data-umami-event-target_id`
|
||||
- keep `data-umami-event-placement`
|
||||
- keep `data-umami-event-target_url` for links
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- (none)
|
||||
|
||||
### Modified Capabilities
|
||||
- `interaction-tracking-taxonomy`: add/standardize optional content metadata fields (`title`, `type`) for tracked click events, and define allowed values for `type`.
|
||||
- `analytics-umami`: require Umami Track Events data-attribute instrumentation to support the above additional `data-umami-event-*` properties on content-related clickables.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: shared link/card components and content listing/detail pages (videos, podcast, blog posts/pages, and any other instrumented content surfaces).
|
||||
- Data: Umami event payloads will include two additional string fields for content links; dashboards/reports can segment by `type` and view top-clicked items by `title`.
|
||||
@@ -0,0 +1,36 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Custom event tracking
|
||||
When Umami is enabled, the site MUST support custom event emission for:
|
||||
- `cta_click`
|
||||
- `outbound_click`
|
||||
- a general click interaction event for all instrumented clickable items (per the site tracking taxonomy)
|
||||
|
||||
Each emitted event MUST include enough properties to segment reports by platform and placement when applicable.
|
||||
|
||||
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
|
||||
|
||||
The site MUST instrument tracked clickables using Umami’s supported Track Events data-attribute method:
|
||||
- `data-umami-event="<event-name>"`
|
||||
- optional event data using `data-umami-event-*`
|
||||
|
||||
For content-related links (clickables representing a specific piece of content), the site MUST also provide the following Umami event data attributes:
|
||||
- `data-umami-event-title`
|
||||
- `data-umami-event-type`
|
||||
|
||||
#### Scenario: Emit outbound click event
|
||||
- **WHEN** a user clicks a non-CTA outbound link from the homepage
|
||||
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
|
||||
|
||||
#### Scenario: Emit general click event for any clickable
|
||||
- **WHEN** a user clicks an instrumented navigation link
|
||||
- **THEN** the system emits a click interaction event with `target_id` and `placement`
|
||||
|
||||
#### Scenario: Content click includes title and type
|
||||
- **WHEN** a user clicks an instrumented content link (video, podcast episode, blog post/page)
|
||||
- **THEN** the emitted Umami event includes `title` and `type` properties via `data-umami-event-*` attributes
|
||||
|
||||
#### Scenario: Uninstrumented clicks do not break the page
|
||||
- **WHEN** a user clicks an element with no tracking metadata
|
||||
- **THEN** the system does not throw and navigation/interaction proceeds normally
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Minimum required properties
|
||||
Every tracked click event MUST include, at minimum:
|
||||
- `target_id`
|
||||
- `placement`
|
||||
|
||||
For links, the event MUST also include:
|
||||
- `target_url` (or a stable target identifier that can be mapped to a URL)
|
||||
|
||||
For content-related links (clickables representing a specific piece of content), the event MUST also include:
|
||||
- `title` (human-readable content title)
|
||||
- `type` (content type identifier)
|
||||
|
||||
The `type` value MUST be one of:
|
||||
- `video`
|
||||
- `podcast_episode`
|
||||
- `blog_post`
|
||||
- `blog_page`
|
||||
|
||||
#### Scenario: Tracking a content card click
|
||||
- **WHEN** a user clicks a content card link
|
||||
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
|
||||
|
||||
#### Scenario: Tracking a content link includes title and type
|
||||
- **WHEN** a user clicks a content-related link that represents a specific content item
|
||||
- **THEN** the emitted event includes `target_id`, `placement`, `target_url`, `title`, and `type`
|
||||
|
||||
15
openspec/changes/archive/2026-02-10-better-tracking/tasks.md
Normal file
15
openspec/changes/archive/2026-02-10-better-tracking/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## 1. Update Tracking Taxonomy
|
||||
|
||||
- [x] 1.1 Update shared Umami instrumentation patterns to support optional `title` and `type` event data for content links (without breaking existing events)
|
||||
- [x] 1.2 Ensure content `type` values are normalized (`video`, `podcast_episode`, `blog_post`, `blog_page`) and do not include PII
|
||||
|
||||
## 2. Instrument Content Surfaces
|
||||
|
||||
- [x] 2.1 Add `data-umami-event-title` and `data-umami-event-type` to video clickables (listing cards and detail navigation where applicable)
|
||||
- [x] 2.2 Add `data-umami-event-title` and `data-umami-event-type` to podcast clickables (listing cards and episode links)
|
||||
- [x] 2.3 Add `data-umami-event-title` and `data-umami-event-type` to blog clickables that represent specific content items (post cards, pages list links)
|
||||
|
||||
## 3. Verify
|
||||
|
||||
- [x] 3.1 Add/update tests to assert content clickables include `data-umami-event-title` and `data-umami-event-type` where required
|
||||
- [x] 3.2 Build the site and confirm representative pages render the new data attributes (videos listing, podcast listing, blog listing)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-10
|
||||
@@ -23,6 +23,14 @@ Each emitted event MUST include enough properties to segment reports by platform
|
||||
|
||||
All tracked clickable items MUST emit events with a unique, consistent set of data elements as defined by the site tracking taxonomy, including at minimum `target_id` and `placement`.
|
||||
|
||||
The site MUST instrument tracked clickables using Umami's supported Track Events data-attribute method:
|
||||
- `data-umami-event="<event-name>"`
|
||||
- optional event data using `data-umami-event-*`
|
||||
|
||||
For content-related links (clickables representing a specific piece of content), the site MUST also provide the following Umami event data attributes:
|
||||
- `data-umami-event-title`
|
||||
- `data-umami-event-type`
|
||||
|
||||
#### Scenario: Emit outbound click event
|
||||
- **WHEN** a user clicks a non-CTA outbound link from the homepage
|
||||
- **THEN** the system emits an `outbound_click` event with a property identifying the destination domain
|
||||
@@ -31,6 +39,10 @@ All tracked clickable items MUST emit events with a unique, consistent set of da
|
||||
- **WHEN** a user clicks an instrumented navigation link
|
||||
- **THEN** the system emits a click interaction event with `target_id` and `placement`
|
||||
|
||||
#### Scenario: Content click includes title and type
|
||||
- **WHEN** a user clicks an instrumented content link (video, podcast episode, blog post/page)
|
||||
- **THEN** the emitted Umami event includes `title` and `type` properties via `data-umami-event-*` attributes
|
||||
|
||||
#### Scenario: Uninstrumented clicks do not break the page
|
||||
- **WHEN** a user clicks an element with no tracking metadata
|
||||
- **THEN** the system does not throw and navigation/interaction proceeds normally
|
||||
|
||||
@@ -19,10 +19,20 @@ The site MUST provide a blog index page at `/blog` that lists WordPress posts as
|
||||
|
||||
The listing MUST be ordered by publish date descending (newest first).
|
||||
|
||||
Each post card MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
|
||||
- `data-umami-event`
|
||||
- `data-umami-event-target_id`
|
||||
- `data-umami-event-placement`
|
||||
- `data-umami-event-target_url`
|
||||
|
||||
#### Scenario: Blog index lists posts
|
||||
- **WHEN** the cached WordPress dataset contains posts
|
||||
- **THEN** `/blog` renders a list of post cards ordered by publish date descending
|
||||
|
||||
#### Scenario: Blog post card click is tracked
|
||||
- **WHEN** a user clicks a blog post card on `/blog`
|
||||
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
|
||||
|
||||
### Requirement: Blog post detail
|
||||
The site MUST provide a blog post detail page for each WordPress post that renders:
|
||||
- title
|
||||
@@ -49,6 +59,12 @@ The blog section MUST render a secondary navigation under the header derived fro
|
||||
|
||||
Selecting a category MUST navigate to a category listing page showing only posts in that category.
|
||||
|
||||
Each secondary navigation link MUST be instrumented with Umami Track Events data attributes and MUST include at minimum:
|
||||
- `data-umami-event`
|
||||
- `data-umami-event-target_id`
|
||||
- `data-umami-event-placement`
|
||||
- `data-umami-event-target_url`
|
||||
|
||||
#### Scenario: Category nav present
|
||||
- **WHEN** the cached WordPress dataset contains categories
|
||||
- **THEN** the blog section shows a secondary navigation with those categories
|
||||
@@ -57,6 +73,10 @@ Selecting a category MUST navigate to a category listing page showing only posts
|
||||
- **WHEN** a user navigates to a category listing page
|
||||
- **THEN** only posts assigned to that category are listed
|
||||
|
||||
#### Scenario: Category nav click is tracked
|
||||
- **WHEN** a user clicks a category link in the blog secondary navigation
|
||||
- **THEN** the click emits an Umami event with `target_id`, `placement`, and `target_url`
|
||||
|
||||
### Requirement: Graceful empty states
|
||||
If there are no WordPress posts available, the blog index MUST render a non-broken empty state and MUST still render header/navigation.
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ Every clickable item that is tracked MUST have a stable identifier (`target_id`)
|
||||
|
||||
The identifier MUST be deterministic across builds for the same element and placement.
|
||||
|
||||
The taxonomy MUST define namespaces for repeated UI surfaces. For the blog surface, the following namespaces MUST be used:
|
||||
- `blog.subnav.*` for secondary navigation links
|
||||
- `blog.card.post.<slug>` for blog post cards
|
||||
- `blog.pages.link.<slug>` for blog page listing links
|
||||
- `blog.post.*` / `blog.page.*` for detail page chrome links (e.g., back links)
|
||||
|
||||
#### Scenario: Two links in different placements
|
||||
- **WHEN** two links point to the same destination but appear in different placements
|
||||
- **THEN** their `target_id` values are different so their clicks can be measured independently
|
||||
@@ -30,14 +36,27 @@ Every tracked click event MUST include, at minimum:
|
||||
For links, the event MUST also include:
|
||||
- `target_url` (or a stable target identifier that can be mapped to a URL)
|
||||
|
||||
For content-related links (clickables representing a specific piece of content), the event MUST also include:
|
||||
- `title` (human-readable content title)
|
||||
- `type` (content type identifier)
|
||||
|
||||
The `type` value MUST be one of:
|
||||
- `video`
|
||||
- `podcast_episode`
|
||||
- `blog_post`
|
||||
- `blog_page`
|
||||
|
||||
#### Scenario: Tracking a content card click
|
||||
- **WHEN** a user clicks a content card link
|
||||
- **THEN** the emitted event includes `target_id`, `placement`, and `target_url`
|
||||
|
||||
#### Scenario: Tracking a content link includes title and type
|
||||
- **WHEN** a user clicks a content-related link that represents a specific content item
|
||||
- **THEN** the emitted event includes `target_id`, `placement`, `target_url`, `title`, and `type`
|
||||
|
||||
### Requirement: No PII in event properties
|
||||
The taxonomy MUST prohibit including personally identifiable information (PII) in event names or event properties.
|
||||
|
||||
#### Scenario: Tracking includes only categorical metadata
|
||||
- **WHEN** tracking metadata is defined for a clickable item
|
||||
- **THEN** it contains only categorical identifiers (ids, placements, domains) and does not include user-provided content
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ function truncate(s: string, n: number) {
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={placement}
|
||||
data-umami-event-target_url={`/blog/post/${post.slug}`}
|
||||
data-umami-event-title={truncate(post.title || "", 160)}
|
||||
data-umami-event-type="blog_post"
|
||||
>
|
||||
{post.featuredImageUrl ? <img src={post.featuredImageUrl} alt="" loading="lazy" /> : null}
|
||||
<div class="blog-card-body">
|
||||
|
||||
@@ -7,6 +7,12 @@ type Props = {
|
||||
};
|
||||
|
||||
const { item, placement } = Astro.props;
|
||||
|
||||
function truncate(s: string, n: number) {
|
||||
const t = (s || "").trim();
|
||||
if (t.length <= n) return t;
|
||||
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
|
||||
}
|
||||
const d = new Date(item.publishedAt);
|
||||
const dateLabel = Number.isFinite(d.valueOf())
|
||||
? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
|
||||
@@ -19,6 +25,15 @@ try {
|
||||
} catch {
|
||||
domain = "";
|
||||
}
|
||||
|
||||
const umamiType =
|
||||
item.source === "youtube"
|
||||
? "video"
|
||||
: item.source === "podcast"
|
||||
? "podcast_episode"
|
||||
: undefined;
|
||||
|
||||
const umamiTitle = umamiType ? truncate(item.title, 160) : undefined;
|
||||
---
|
||||
|
||||
<a
|
||||
@@ -30,6 +45,8 @@ try {
|
||||
data-umami-event-target_id={targetId}
|
||||
data-umami-event-placement={placement}
|
||||
data-umami-event-target_url={item.url}
|
||||
data-umami-event-title={umamiTitle}
|
||||
data-umami-event-type={umamiType}
|
||||
data-umami-event-domain={domain || "unknown"}
|
||||
data-umami-event-source={item.source}
|
||||
data-umami-event-ui_placement="content_card"
|
||||
|
||||
@@ -57,6 +57,8 @@ const pages = wordpressPages(cache);
|
||||
data-umami-event-target_id={`blog.index.pages.link.${p.slug}`}
|
||||
data-umami-event-placement="blog.index.pages_preview"
|
||||
data-umami-event-target_url={`/blog/page/${p.slug}`}
|
||||
data-umami-event-title={p.title}
|
||||
data-umami-event-type="blog_page"
|
||||
>
|
||||
{p.title}
|
||||
</a>
|
||||
|
||||
@@ -31,6 +31,8 @@ const pages = wordpressPages(cache);
|
||||
data-umami-event-target_id={`blog.pages.link.${p.slug}`}
|
||||
data-umami-event-placement="blog.pages.list"
|
||||
data-umami-event-target_url={`/blog/page/${p.slug}`}
|
||||
data-umami-event-title={p.title}
|
||||
data-umami-event-type="blog_page"
|
||||
>
|
||||
{p.title}
|
||||
</a>
|
||||
|
||||
@@ -25,6 +25,12 @@ try {
|
||||
} catch {
|
||||
episodeDomain = "";
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number) {
|
||||
const t = (s || "").trim();
|
||||
if (t.length <= n) return t;
|
||||
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -62,6 +68,8 @@ try {
|
||||
data-umami-event-target_id={`podcast_detail.open.${episode.id}`}
|
||||
data-umami-event-placement="podcast_detail"
|
||||
data-umami-event-target_url={episode.url}
|
||||
data-umami-event-title={truncate(episode.title, 160)}
|
||||
data-umami-event-type="podcast_episode"
|
||||
data-umami-event-domain={episodeDomain || "unknown"}
|
||||
data-umami-event-source="podcast"
|
||||
data-umami-event-ui_placement="podcast_detail"
|
||||
|
||||
@@ -26,6 +26,12 @@ try {
|
||||
} catch {
|
||||
videoDomain = "";
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number) {
|
||||
const t = (s || "").trim();
|
||||
if (t.length <= n) return t;
|
||||
return `${t.slice(0, Math.max(0, n - 1)).trimEnd()}…`;
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -63,6 +69,8 @@ try {
|
||||
data-umami-event-target_id={`video_detail.watch.${video.id}`}
|
||||
data-umami-event-placement="video_detail"
|
||||
data-umami-event-target_url={video.url}
|
||||
data-umami-event-title={truncate(video.title, 160)}
|
||||
data-umami-event-type="video"
|
||||
data-umami-event-domain={videoDomain || "unknown"}
|
||||
data-umami-event-source="youtube"
|
||||
data-umami-event-ui_placement="video_detail"
|
||||
|
||||
46
site/tests/content-title-type-attributes.test.ts
Normal file
46
site/tests/content-title-type-attributes.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
async function read(rel: string) {
|
||||
return await readFile(path.join(process.cwd(), rel), "utf8");
|
||||
}
|
||||
|
||||
describe("content link umami title/type attributes", () => {
|
||||
it("adds title/type on outbound content cards (youtube/podcast) without forcing instagram", async () => {
|
||||
const src = await read("src/components/ContentCard.astro");
|
||||
|
||||
expect(src).toContain("data-umami-event-title");
|
||||
expect(src).toContain("data-umami-event-type");
|
||||
expect(src).toContain('item.source === "youtube"');
|
||||
expect(src).toContain('item.source === "podcast"');
|
||||
});
|
||||
|
||||
it("adds title/type on video detail outbound CTA", async () => {
|
||||
const src = await read("src/pages/videos/[id].astro");
|
||||
expect(src).toContain('data-umami-event-title={truncate(video.title, 160)}');
|
||||
expect(src).toContain('data-umami-event-type="video"');
|
||||
});
|
||||
|
||||
it("adds title/type on podcast detail outbound CTA", async () => {
|
||||
const src = await read("src/pages/podcast/[id].astro");
|
||||
expect(src).toContain('data-umami-event-title={truncate(episode.title, 160)}');
|
||||
expect(src).toContain('data-umami-event-type="podcast_episode"');
|
||||
});
|
||||
|
||||
it("adds title/type on blog post cards and pages links", async () => {
|
||||
const cardSrc = await read("src/components/BlogPostCard.astro");
|
||||
expect(cardSrc).toContain('data-umami-event-type="blog_post"');
|
||||
expect(cardSrc).toContain("data-umami-event-title");
|
||||
|
||||
const blogIndexSrc = await read("src/pages/blog/index.astro");
|
||||
expect(blogIndexSrc).toContain('data-umami-event-type="blog_page"');
|
||||
expect(blogIndexSrc).toContain("data-umami-event-title={p.title}");
|
||||
|
||||
const blogPagesSrc = await read("src/pages/blog/pages.astro");
|
||||
expect(blogPagesSrc).toContain('data-umami-event-type="blog_page"');
|
||||
expect(blogPagesSrc).toContain("data-umami-event-title={p.title}");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user