7# Frontend Analytics Events Skill
8
9This skill helps you add product analytics (Snowplow) events to track user interactions in the Metabase frontend codebase.
10
11## Quick Reference
12
13Analytics events in Metabase use Snowplow with typed event schemas. Simple events are declared **where they are used** — trackSimpleEvent is generic and validates the payload at the call site.
14
15**Key Files:**
16- frontend/src/metabase/analytics/event.ts - Core tracking functions, trackSimpleEvent / trackSchemaEvent (import from metabase/analytics)
17- frontend/src/metabase-types/analytics/event.ts - The shared SimpleEventSchema only. **Do not add event types here** (see below)
18- frontend/src/metabase-types/analytics/schema.ts - Schema registry (custom/legacy schemas only)
19- Feature-specific analytics.ts files - Where your tracking functions and any local types live
20
21## Quick Checklist
22
23When adding a new analytics event:
24
25- [ ] Pick an event name (snake_case, past tense)
26- [ ] Add a tracking function to the feature's analytics.ts file, calling trackSimpleEvent()
27- [ ] Keep any field unions (e.g. "success" | "failure") as local types in that same file
28- [ ] Import and call the tracking function at the interaction point
29- [ ] Do **not** add an event type to metabase-types/analytics/event.ts or to any union
30
31## Event Schema Types
32
33### 1. Simple Events (Most Common)
34
35Use SimpleEventSchema for straightforward tracking. It supports these standard fields:
36
37```typescript
38type SimpleEventSchema = {
39 event: string; // Required: Event name (snake_case)
40 target_id?: number | null; // Optional: ID of affected entity
41 triggered_from?: string | null; // Optional: UI location/context
42 duration_ms?: number | null; // Optional: Duration in milliseconds
43 result?: string | null; // Optional: Outcome (e.g., "success", "failure")
44 event_detail?: string | null; // Optional: Additional detail/variant
45};
46```
47
48**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
49
50trackSimpleEvent is generic and enforces this schema on the object literal you pass it:
51
52```typescript
53// frontend/src/metabase/analytics/event.ts
54export function trackSimpleEvent<
55 T extends SimpleEventSchema &
56 Record<Exclude<keyof T, keyof SimpleEventSchema>, never>,
57>(event: T) {
58 trackSchemaEvent("simple_event", event);
59}
60```
61
62That means a missing event or any field outside SimpleEventSchema is a compile error at the call
63site. There is no separate event type to declare and no satisfies clause to add — the old
64ValidateEvent<...> helper is no longer exported and is not part of the workflow.
65
66trackSchemaEvent is generic too: it correlates the schema name with the payload type, so you can't
67send a dashboard event under the simple_event schema.
68
69### 2. Custom Schemas (legacy, no events are being added)
70
71Consider adding new event schema only in very special cases.
72
73**Examples:** DashboardEventSchema, CleanupEventSchema, QuestionEventSchema
74
75## Step-by-Step: Adding a Simple Event
76
77### Example: Track when a user applies filters in a table picker
78
79#### Step 1: Create Tracking Functions
80
81In your feature's analytics.ts file (e.g., enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts):
82
83```typescript
84import { trackSimpleEvent } from "metabase/analytics";
85
86export const trackDataStudioTablePickerFiltersApplied = () => {
87 trackSimpleEvent({
88 event: "data_studio_table_picker_filters_applied",
89 });
90};
91
92export const trackDataStudioTablePickerFiltersCleared = () => {
93 trackSimpleEvent({
94 event: "data_studio_table_picker_filters_cleared",
95 });
96};
97```
98
99#### Step 2: Use in Components
100
101Import and call the tracking function at the interaction point:
102
103```typescript
104import {
105 trackDataStudioTablePickerFiltersApplied,
106 trackDataStudioTablePickerFiltersCleared,
107} from "metabase-enterprise/data-studio/analytics";
108
109function FilterPopover({ filters, onSubmit }) {
110 const handleReset = () => {
111 trackDataStudioTablePickerFiltersCleared(); // <- Track here
112 onSubmit(emptyFilters);
113 };
114
115 return (
116 <form
117 onSubmit={(event) => {
118 event.preventDefault();
119 trackDataStudioTablePickerFiltersApplied(); // <- Track here
120 onSubmit(form);
121 }}
122 >
123 {/* form content */}
124 </form>
125 );
126}
127```
128
129## Using SimpleEventSchema Fields
130
131All examples below live in the feature's own analytics.ts — nothing is registered centrally.
132
133### Example: Event with target_id
134
135```typescript
136export const trackDataStudioLibraryCreated = (id: CollectionId) => {
137 trackSimpleEvent({
138 event: "data_studio_library_created",
139 target_id: Number(id),
140 });
141};
142
143// Usage
144trackDataStudioLibraryCreated(newLibrary.id);
145```
146
147### Example: Event with triggered_from
148
149```typescript
150// Local union, exported only if another feature needs to pass the same value
151export type NewButtonLocation = "app-bar" | "empty-collection";
152
153export const trackNewButtonClicked = (location: NewButtonLocation) => {
154 trackSimpleEvent({
155 event: "new_button_clicked",
156 triggered_from: location,
157 });
158};
159
160// Usage
161<Button onClick={() => {
162 trackNewButtonClicked("app-bar");
163 handleCreate();
164}}>
165 New
166</Button>
167```
168
169### Example: Event with event_detail
170
171Real example — frontend/src/metabase/metadata/pages/shared/analytics.ts:
172
173```typescript
174export type MetadataEditEventDetail =
175 | "type_casting"
176 | "semantic_type_change"
177 | "visibility_change";
178
179export const trackMetadataChange = (detail: MetadataEditEventDetail) => {
180 trackSimpleEvent({
181 event: "metadata_edited",
182 event_detail: detail,
183 triggered_from: "admin",
184 });
185};
186
187// Usage
188trackMetadataChange("semantic_type_change");
189```
190
191### Example: Event with result and duration
192
193See frontend/src/metabase/archive/analytics.ts for the real version of this.
194
195```typescript
196export const trackMoveToTrash = (params: {
197 targetId: number | null;
198 triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
199 durationMs: number | null;
200 result: "success" | "failure";
201 itemType: "question" | "model" | "metric" | "dashboard";
202}) => {
203 trackSimpleEvent({
204 event: "moved-to-trash",
205 target_id: params.targetId,
206 triggered_from: params.triggeredFrom,
207 duration_ms: params.durationMs,
208 result: params.result,
209 event_detail: params.itemType,
210 });
211};
212
213// Usage with timing
214const startTime = Date.now();
215try {
216 await moveToTrash(item);
217 trackMoveToTrash({
218 targetId: item.id,
219 triggeredFrom: "collection",
220 durationMs: Date.now() - startTime,
221 result: "success",
222 itemType: "question",
223 });
224} catch (error) {
225 trackMoveToTrash({
226 targetId: item.id,
227 triggeredFrom: "collection",
228 durationMs: Date.now() - startTime,
229 result: "failure",
230 itemType: "question",
231 });
232}
233```
234
235## Naming Conventions
236
237### Event Names (snake_case)
238
239```typescript
240// Good
241"data_studio_library_created"
242"table_picker_filters_applied"
243"metabot_chat_opened"
244
245// Bad
246"DataStudioLibraryCreated" // Wrong case
247"tablePickerFiltersApplied" // Wrong case
248"filters-applied" // Use underscore, not hyphen
249```
250
251### Local Field Types (PascalCase, named after the field)
252
253There is usually no ...Event type to name anymore. When you do need a union for a field, name it
254after the field it feeds:
255
256```typescript
257// Good
258type MetricDimensionResult = "success" | "failure"; // -> result
259export type MetadataEditEventDetail = "type_casting"; // -> event_detail
260type NewButtonLocation = "app-bar" | "empty-collection"; // -> triggered_from
261```
262
263### Tracking Function Names (camelCase with "track" prefix)
264
265```typescript
266// Good
267trackDataStudioLibraryCreated
268trackTablePickerFiltersApplied
269trackMetabotChatOpened
270
271// Bad
272DataStudioLibraryCreated // Missing "track" prefix
273track_library_created // Wrong case
274logLibraryCreated // Use "track" prefix
275```
276
277## Common Patterns
278
279### Pattern 1: Sharing Field Types Across Features
280
281When two features send the same event with a different triggered_from, export the field union from
282the owning feature's analytics.ts and import it — don't hoist anything into metabase-types:
283
284```typescript
285// frontend/src/metabase/data-studio/data-model/analytics.ts
286import { trackSimpleEvent } from "metabase/analytics";
287import type { MetadataEditEventDetail } from "metabase/metadata/pages/shared/analytics";
288
289export function trackMetadataChange(detail: MetadataEditEventDetail) {
290 trackSimpleEvent({
291 event: "metadata_edited",
292 event_detail: detail,
293 triggered_from: "data_studio",
294 });
295}
296```
297
298This is the point of the extensible-events design: enterprise and feature-tier types stay in their
299own module instead of being imported down into a shared union.
300
301### Pattern 2: Conditional Tracking
302
303Track different events based on user action:
304
305```typescript
306const handleSave = async () => {
307 if (isNewItem) {
308 await createItem(data);
309 trackItemCreated(newItem.id);
310 } else {
311 await updateItem(id, data);
312 trackItemUpdated(id);
313 }
314};
315```
316
317## Common Pitfalls
318
319### Don't: Add custom fields to a simple event
320
321```typescript
322// WRONG - SimpleEventSchema doesn't support custom fields (this is a compile error)
323export const trackFiltersApplied = (filters: FilterState) => {
324 trackSimpleEvent({
325 event: "filters_applied",
326 data_layer: filters.dataLayer, // ❌ Not in SimpleEventSchema
327 data_source: filters.dataSource, // ❌ Not in SimpleEventSchema
328 with_owner: filters.hasOwner, // ❌ Not in SimpleEventSchema
329 });
330};
331
332// RIGHT - Use only standard SimpleEventSchema fields
333export const trackFiltersApplied = () => {
334 trackSimpleEvent({
335 event: "filters_applied",
336 });
337};
338
339// Or use event_detail for a single variant
340export const trackFilterApplied = (filterType: string) => {
341 trackSimpleEvent({
342 event: "filter_applied",
343 event_detail: filterType, // ✓ "data_layer", "data_source", etc.
344 });
345};
346```
347
348### Don't: Add event types to metabase-types/analytics/event.ts
349
350The central SimpleEvent union was removed — it forced feature-tier types to be imported down into
351shared code, causing module-boundary violations. trackSimpleEvent is generic now, so the type adds
352nothing but duplication.
353
354```typescript
355// ❌ WRONG - central declaration + re-import for a satisfies clause
356// frontend/src/metabase-types/analytics/event.ts
357export type NewFeatureClickedEvent = ValidateEvent<{
358 event: "new_feature_clicked";
359 target_id: number;
360}>;
361
362// frontend/src/metabase/my-feature/analytics.ts
363import type { NewFeatureClickedEvent } from "metabase-types/analytics";
364
365export const trackNewFeatureClicked = (id: number) => {
366 trackSimpleEvent({
367 event: "new_feature_clicked",
368 target_id: id,
369 } satisfies NewFeatureClickedEvent);
370};
371
372// ✓ RIGHT - the object literal is already checked by the generic
373// frontend/src/metabase/my-feature/analytics.ts
374export const trackNewFeatureClicked = (id: number) => {
375 trackSimpleEvent({
376 event: "new_feature_clicked",
377 target_id: id,
378 });
379};
380```
381
382A few ...Event types still sit in metabase-types/analytics/event.ts. They are leftovers from PRs
383that landed around the refactor — don't copy them, and don't add to them.
384
385### Don't: Mix up event name formats
386
387```typescript
388// WRONG
389event: "dataStudioLibraryCreated" // camelCase
390event: "data-studio-library-created" // kebab-case
391event: "Data_Studio_Library_Created" // Mixed case
392
393// RIGHT
394event: "data_studio_library_created" // snake_case
395```
396
397### Don't: Track PII or sensitive data
398
399```typescript
400// WRONG - Don't track user emails, names, or sensitive data
401trackSimpleEvent({
402 event: "user_logged_in",
403 event_detail: user.email, // ❌ PII
404});
405
406// RIGHT - Track non-sensitive identifiers only
407trackSimpleEvent({
408 event: "user_logged_in",
409 target_id: user.id, // ✓ Just the ID
410});
411```
412
413### Don't: Forget to track both success and failure
414
415```typescript
416// WRONG - Only tracking success
417try {
418 await saveData();
419 trackDataSaved();
420} catch (error) {
421 // ❌ No tracking for failure case
422}
423
424// RIGHT - Track both outcomes
425try {
426 await saveData();
427 trackDataSaved({ result: "success" });
428} catch (error) {
429 trackDataSaved({ result: "failure" });
430}
431```
432
433## Testing Analytics Events
434
435While developing, you can verify events are firing:
436
4371. **Check browser console** - When SNOWPLOW_ENABLED=true in dev, events are logged
4382. **Use shouldLogAnalytics** - Set in metabase/env to see all analytics in console
4393. **Check Snowplow debugger** - Browser extension for Snowplow events
440
441Example console output:
442
443```
444[SNOWPLOW EVENT | event sent:true], data_studio_table_picker_filters_applied
445```
446
447## File Organization
448
449### Where to put tracking functions:
450
451```
452Tracking functions AND their local field types (this is where new events live):
453frontend/src/metabase/{feature}/analytics.ts
454enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
455
456Core tracking utilities:
457frontend/src/metabase/analytics/ (import from metabase/analytics)
458
459Shared SimpleEventSchema only — nothing new goes here:
460frontend/src/metabase-types/analytics/event.ts
461```
462
463In embedding SDK code, use trackSdkSimpleEvent
464(frontend/src/embedding-sdk-bundle/analytics/snowplow.ts) instead — the main-app "sp" tracker
465isn't initialized in the customer's page, so trackSimpleEvent's Snowplow leg is a no-op there.
466
467## Real-World Examples
468
469See these files for reference:
470
471- **Simple events + local field union**: frontend/src/metabase/metadata/pages/shared/analytics.ts
472- **Reusing another feature's field type**: frontend/src/metabase/data-studio/data-model/analytics.ts
473- **Result + duration timing**: frontend/src/metabase/archive/analytics.ts
474- **Enterprise feature events**: enterprise/frontend/src/metabase-enterprise/google_drive/analytics.ts
475
476## Workflow Summary
477
4781. **Identify the user interaction** to track
4792. **Decide on event name** (snake_case, descriptive)
4803. **Create tracking function** in feature's analytics.ts, calling trackSimpleEvent()
4814. **Add local field unions** in that same file if a field has a fixed set of values
4825. **Import and call** at the interaction point
4836. **Test** that events fire correctly
484
485## Tips
486
487- **Be specific** - filters_applied is better than action_performed
488- **Use past tense** - library_created not create_library
489- **Group related events** - Keep a feature's tracking functions together in its analytics.ts
490- **Track meaningful actions** - Not every click needs tracking
491- **Consider the data** - What would you want to analyze later?
492- **Stay consistent** - Follow existing naming patterns in the codebase
493- **Document context** - Use triggered_from to track where the action happened
494