better tracking

This commit is contained in:
2026-02-10 01:52:41 -05:00
parent c1ab51a149
commit b63c62a732
23 changed files with 302 additions and 1 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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

View File

@@ -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`

View 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)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-10

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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">

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View 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}");
});
});