diff --git a/openspec/changes/blog-umami-fix/.openspec.yaml b/openspec/changes/archive/2026-02-10-better-tracking/.openspec.yaml similarity index 100% rename from openspec/changes/blog-umami-fix/.openspec.yaml rename to openspec/changes/archive/2026-02-10-better-tracking/.openspec.yaml diff --git a/openspec/changes/archive/2026-02-10-better-tracking/design.md b/openspec/changes/archive/2026-02-10-better-tracking/design.md new file mode 100644 index 0000000..e2e04c4 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-better-tracking/design.md @@ -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 `` 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. + diff --git a/openspec/changes/archive/2026-02-10-better-tracking/proposal.md b/openspec/changes/archive/2026-02-10-better-tracking/proposal.md new file mode 100644 index 0000000..0a6b8fb --- /dev/null +++ b/openspec/changes/archive/2026-02-10-better-tracking/proposal.md @@ -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`. diff --git a/openspec/changes/archive/2026-02-10-better-tracking/specs/analytics-umami/spec.md b/openspec/changes/archive/2026-02-10-better-tracking/specs/analytics-umami/spec.md new file mode 100644 index 0000000..fb90af4 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-better-tracking/specs/analytics-umami/spec.md @@ -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=""` +- 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 + diff --git a/openspec/changes/archive/2026-02-10-better-tracking/specs/interaction-tracking-taxonomy/spec.md b/openspec/changes/archive/2026-02-10-better-tracking/specs/interaction-tracking-taxonomy/spec.md new file mode 100644 index 0000000..832375a --- /dev/null +++ b/openspec/changes/archive/2026-02-10-better-tracking/specs/interaction-tracking-taxonomy/spec.md @@ -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` + diff --git a/openspec/changes/archive/2026-02-10-better-tracking/tasks.md b/openspec/changes/archive/2026-02-10-better-tracking/tasks.md new file mode 100644 index 0000000..9e9c80f --- /dev/null +++ b/openspec/changes/archive/2026-02-10-better-tracking/tasks.md @@ -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) diff --git a/openspec/changes/archive/2026-02-10-blog-umami-fix/.openspec.yaml b/openspec/changes/archive/2026-02-10-blog-umami-fix/.openspec.yaml new file mode 100644 index 0000000..70eb9e0 --- /dev/null +++ b/openspec/changes/archive/2026-02-10-blog-umami-fix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-10 diff --git a/openspec/changes/blog-umami-fix/design.md b/openspec/changes/archive/2026-02-10-blog-umami-fix/design.md similarity index 100% rename from openspec/changes/blog-umami-fix/design.md rename to openspec/changes/archive/2026-02-10-blog-umami-fix/design.md diff --git a/openspec/changes/blog-umami-fix/proposal.md b/openspec/changes/archive/2026-02-10-blog-umami-fix/proposal.md similarity index 100% rename from openspec/changes/blog-umami-fix/proposal.md rename to openspec/changes/archive/2026-02-10-blog-umami-fix/proposal.md diff --git a/openspec/changes/blog-umami-fix/specs/analytics-umami/spec.md b/openspec/changes/archive/2026-02-10-blog-umami-fix/specs/analytics-umami/spec.md similarity index 100% rename from openspec/changes/blog-umami-fix/specs/analytics-umami/spec.md rename to openspec/changes/archive/2026-02-10-blog-umami-fix/specs/analytics-umami/spec.md diff --git a/openspec/changes/blog-umami-fix/specs/blog-section-surface/spec.md b/openspec/changes/archive/2026-02-10-blog-umami-fix/specs/blog-section-surface/spec.md similarity index 100% rename from openspec/changes/blog-umami-fix/specs/blog-section-surface/spec.md rename to openspec/changes/archive/2026-02-10-blog-umami-fix/specs/blog-section-surface/spec.md diff --git a/openspec/changes/blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md b/openspec/changes/archive/2026-02-10-blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md similarity index 100% rename from openspec/changes/blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md rename to openspec/changes/archive/2026-02-10-blog-umami-fix/specs/interaction-tracking-taxonomy/spec.md diff --git a/openspec/changes/blog-umami-fix/tasks.md b/openspec/changes/archive/2026-02-10-blog-umami-fix/tasks.md similarity index 100% rename from openspec/changes/blog-umami-fix/tasks.md rename to openspec/changes/archive/2026-02-10-blog-umami-fix/tasks.md diff --git a/openspec/specs/analytics-umami/spec.md b/openspec/specs/analytics-umami/spec.md index a80749d..4ff75c7 100644 --- a/openspec/specs/analytics-umami/spec.md +++ b/openspec/specs/analytics-umami/spec.md @@ -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=""` +- 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 diff --git a/openspec/specs/blog-section-surface/spec.md b/openspec/specs/blog-section-surface/spec.md index 607f969..88ae3bb 100644 --- a/openspec/specs/blog-section-surface/spec.md +++ b/openspec/specs/blog-section-surface/spec.md @@ -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. diff --git a/openspec/specs/interaction-tracking-taxonomy/spec.md b/openspec/specs/interaction-tracking-taxonomy/spec.md index d73ac95..fa4a78c 100644 --- a/openspec/specs/interaction-tracking-taxonomy/spec.md +++ b/openspec/specs/interaction-tracking-taxonomy/spec.md @@ -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.` for blog post cards +- `blog.pages.link.` 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 - diff --git a/site/src/components/BlogPostCard.astro b/site/src/components/BlogPostCard.astro index 0c618c8..2f416ef 100644 --- a/site/src/components/BlogPostCard.astro +++ b/site/src/components/BlogPostCard.astro @@ -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 ? : null}
diff --git a/site/src/components/ContentCard.astro b/site/src/components/ContentCard.astro index c8a6d7b..fe9913d 100644 --- a/site/src/components/ContentCard.astro +++ b/site/src/components/ContentCard.astro @@ -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; --- {p.title} diff --git a/site/src/pages/blog/pages.astro b/site/src/pages/blog/pages.astro index cc8751c..d8fa09e 100644 --- a/site/src/pages/blog/pages.astro +++ b/site/src/pages/blog/pages.astro @@ -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} diff --git a/site/src/pages/podcast/[id].astro b/site/src/pages/podcast/[id].astro index fc2dfcb..124cc4b 100644 --- a/site/src/pages/podcast/[id].astro +++ b/site/src/pages/podcast/[id].astro @@ -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()}…`; +} --- { + 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}"); + }); +}); +