From b7e766ccc2940f5d54ce1be7cd967b7e7ed043b8 Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Mon, 6 Apr 2026 14:48:27 +0100 Subject: [PATCH 01/10] chore: setup insights events tracking Signed-off-by: Joana Maia --- .../V1775900000__createEventsTable.sql | 21 ++++++ frontend/composables/useTrackEvent.ts | 35 +++++++++ frontend/server/api/events/index.post.ts | 72 +++++++++++++++++++ frontend/server/middleware/database.ts | 1 + frontend/server/repo/events.repo.ts | 48 +++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 database/migrations/V1775900000__createEventsTable.sql create mode 100644 frontend/composables/useTrackEvent.ts create mode 100644 frontend/server/api/events/index.post.ts create mode 100644 frontend/server/repo/events.repo.ts diff --git a/database/migrations/V1775900000__createEventsTable.sql b/database/migrations/V1775900000__createEventsTable.sql new file mode 100644 index 000000000..b8aada35a --- /dev/null +++ b/database/migrations/V1775900000__createEventsTable.sql @@ -0,0 +1,21 @@ +-- Events table for tracking user interactions throughout the application +CREATE TABLE IF NOT EXISTS public.events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + user_id TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + properties JSONB, + feature TEXT, + source TEXT, + entry_source TEXT +); + +-- Indexes for common query patterns +CREATE INDEX idx_events_key ON public.events(key); +CREATE INDEX idx_events_type ON public.events(type); +CREATE INDEX idx_events_user_id ON public.events(user_id); +CREATE INDEX idx_events_feature ON public.events(feature); +CREATE INDEX idx_events_created_at ON public.events(created_at DESC); diff --git a/frontend/composables/useTrackEvent.ts b/frontend/composables/useTrackEvent.ts new file mode 100644 index 000000000..1cd3a669c --- /dev/null +++ b/frontend/composables/useTrackEvent.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { useRoute } from 'nuxt/app'; + +export interface TrackEventPayload { + key: string; + type: string; + name: string; + description?: string; + properties?: Record; + feature?: string; +} + +export function useTrackEvent() { + const route = useRoute(); + + const trackEvent = async (payload: TrackEventPayload): Promise => { + if (!process.client) return; + + try { + await $fetch('/api/events', { + method: 'POST', + body: { + ...payload, + source: window.location.href, + entrySource: document.referrer || undefined, + }, + }); + } catch { + // Event tracking failures should never break the user experience + } + }; + + return { trackEvent, route }; +} diff --git a/frontend/server/api/events/index.post.ts b/frontend/server/api/events/index.post.ts new file mode 100644 index 000000000..5cc3ef42b --- /dev/null +++ b/frontend/server/api/events/index.post.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import type { Pool } from 'pg'; +import { EventsRepository } from '~~/server/repo/events.repo'; +import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types'; + +/** + * API Endpoint: POST /api/events + * Description: Tracks a user interaction event. + * + * Request Body: + * - key (string, required): Unique event key/identifier + * - type (string, required): Event type category + * - name (string, required): Human-readable event name + * - description (string, optional): Event description + * - properties (object, optional): Arbitrary event metadata + * - feature (string, optional): Feature area where the event occurred + * - source (string, optional): URL of the page where the event occurred + * - entrySource (string, optional): URL of the referrer/entry page + * + * Response: + * - 200: { success: true } + * - 400: Validation error + * - 503: Database not available + * - 500: Internal Server Error + */ +export default defineEventHandler(async (event): Promise<{ success: boolean }> => { + const insightsDbPool = event.context.insightsDbPool as Pool | undefined; + + if (!insightsDbPool) { + throw createError({ statusCode: 503, statusMessage: 'Database not available' }); + } + + const body = await readBody<{ + key?: string; + type?: string; + name?: string; + description?: string; + properties?: Record; + feature?: string; + source?: string; + entrySource?: string; + }>(event); + + if (!body?.key?.trim()) { + throw createError({ statusCode: 400, statusMessage: 'key is required' }); + } + if (!body?.type?.trim()) { + throw createError({ statusCode: 400, statusMessage: 'type is required' }); + } + if (!body?.name?.trim()) { + throw createError({ statusCode: 400, statusMessage: 'name is required' }); + } + + const user = event.context.user as DecodedOidcToken | undefined; + + const repo = new EventsRepository(insightsDbPool); + + await repo.track({ + key: body.key.trim(), + type: body.type.trim(), + name: body.name.trim(), + description: body.description?.trim(), + userId: user?.sub, + properties: body.properties, + feature: body.feature?.trim(), + source: body.source?.trim(), + entrySource: body.entrySource?.trim(), + }); + + return { success: true }; +}); diff --git a/frontend/server/middleware/database.ts b/frontend/server/middleware/database.ts index 4bc0cc624..af7dee867 100644 --- a/frontend/server/middleware/database.ts +++ b/frontend/server/middleware/database.ts @@ -12,6 +12,7 @@ export default defineEventHandler(async (event) => { '/api/auth/logout', '/api/collection', '/api/project', + '/api/events', ]; if (allowedRoutes.some((route) => event.node.req.url?.startsWith(route))) { // Add the database pool to the event context diff --git a/frontend/server/repo/events.repo.ts b/frontend/server/repo/events.repo.ts new file mode 100644 index 000000000..b00c387a9 --- /dev/null +++ b/frontend/server/repo/events.repo.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import type { Pool } from 'pg'; + +export interface TrackEventInput { + key: string; + type: string; + name: string; + description?: string; + userId?: string; + properties?: Record; + feature?: string; + source?: string; + entrySource?: string; +} + +export class EventsRepository { + constructor(private pool: Pool) {} + + async track(input: TrackEventInput): Promise { + const query = ` + INSERT INTO public.events ( + key, + type, + name, + description, + user_id, + properties, + feature, + source, + entry_source + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `; + + await this.pool.query(query, [ + input.key, + input.type, + input.name, + input.description ?? null, + input.userId ?? null, + input.properties ? JSON.stringify(input.properties) : null, + input.feature ?? null, + input.source ?? null, + input.entrySource ?? null, + ]); + } +} From b762dbacd5b1476924389702e502da301c6bdfce Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Mon, 6 Apr 2026 15:21:01 +0100 Subject: [PATCH 02/10] chore: add event-tracking skill Signed-off-by: Joana Maia --- .claude/skills/event-tracking/SKILL.md | 135 ++++++++++++++++++ .../references/events-catalog.md | 32 +++++ 2 files changed, 167 insertions(+) create mode 100644 .claude/skills/event-tracking/SKILL.md create mode 100644 .claude/skills/event-tracking/references/events-catalog.md diff --git a/.claude/skills/event-tracking/SKILL.md b/.claude/skills/event-tracking/SKILL.md new file mode 100644 index 000000000..c1d10b665 --- /dev/null +++ b/.claude/skills/event-tracking/SKILL.md @@ -0,0 +1,135 @@ +--- +name: event-tracking +description: > + Add event tracking calls to Vue/Nuxt components in the Insights app using the useTrackEvent composable. + Use this skill whenever a developer asks to "track" a user action or page view, "add analytics", "instrument" a + component, "fire an event", implement an event from the events catalog, or wire up tracking for anything in + the Insights frontend — even if they just say "track when the user clicks X" or "add tracking to this page". +--- + +# Event Tracking — Insights App + +## What this skill does + +Helps you add the right `trackEvent()` call to the right place in the codebase, using the catalog-approved event definitions. + +## Composable location + +`frontend/composables/useTrackEvent.ts` + +```ts +const { trackEvent } = useTrackEvent() + +trackEvent({ + key: 'create-collection', // from catalog — required + type: 'feature', // 'feature' | 'page' — required + name: 'Create collection', // from catalog — required + description: '...', // from catalog — optional + feature: 'Community Collections', // from catalog — optional + properties: { collectionId }, // catalog-defined fields only — optional +}) +``` + +`source` (current URL) and `entrySource` (referrer) are captured **automatically** — never pass them manually. + +The call is always fire-and-forget: errors are caught inside the composable and never bubble up, so you will never break existing functionality by adding it. + +## Step-by-step workflow + +### 1. Read the events catalog + +Always start by reading `references/events-catalog.md` to find the event that matches what the developer described. Copy the `key`, `type`, `name`, `description`, and `feature` values exactly — do not invent or rename them. + +If no catalog entry matches, note this to the developer and suggest the closest match or a new entry following the naming conventions at the bottom of the catalog. + +### 2. Identify the target file + +Ask the developer which component or page to instrument, or infer it from context (e.g. "track when the user creates a collection" → look for the collection creation form/modal component). + +Read the target file before making any changes. You need to understand: +- Whether `useTrackEvent` is already imported +- For **feature** events: which function handles the user action (button click, form submit, etc.) +- For **page** events: whether `onMounted` already exists + +### 3. Insert the tracking call + +#### Feature events (type: `'feature'`) + +Place `trackEvent(...)` inside the action handler, **after** the main logic succeeds. Do not gate it behind a try/catch — the composable handles that internally. + +```ts +const handleCreateCollection = async () => { + // ... existing logic that creates the collection ... + const result = await createCollection(...) + + trackEvent({ + key: 'create-collection', + type: 'feature', + name: 'Create collection', + description: 'User creates a new collection.', + feature: 'Community Collections', + properties: { collectionId: result.id, isPrivate: form.isPrivate }, + }) +} +``` + +For abandonment events (e.g. `abandoned-collection-creation`), fire when the user dismisses or navigates away without completing the flow — typically on modal close or route change before the success state is reached. + +#### Page events (type: `'page'`) + +Place inside `onMounted()`. If it already exists, add to it. If not, add it after the existing composable calls. + +```ts +onMounted(() => { + trackEvent({ + key: 'view-discover-collections', + type: 'page', + name: 'View Discover Collections', + description: 'User views collections entry page.', + feature: 'Community Collections', + }) +}) +``` + +### 4. Add the import (if missing) + +If `useTrackEvent` is not yet imported in the file, add it alongside the other composable imports: + +```ts +const { trackEvent } = useTrackEvent() +``` + +In ` diff --git a/frontend/app/pages/collection/index.vue b/frontend/app/pages/collection/index.vue index 3c0097155..bcb585cfa 100644 --- a/frontend/app/pages/collection/index.vue +++ b/frontend/app/pages/collection/index.vue @@ -9,7 +9,19 @@ SPDX-License-Identifier: MIT