| name | analytics-tracking |
| description | How to track analytics events in tangle-ui and naming conventions for action_type values. Use when adding a new tracked event, naming an action_type, or working with the analytics provider. |
Analytics Tracking
[!IMPORTANT]
Aspirational — not yet implemented in tangent-shell. As of this writing there is no analytics
layer in apps/web: no tracking() helper, no useAnalytics/AnalyticsProvider, no
componentTracking, and none of the @/utils/* paths below exist (they reference the legacy
tangle-ui layout). This skill documents the intended convention for when analytics is added.
Treat the code paths and event taxonomy as a target to build toward, not as APIs you can import
today. When you do wire this up, update this skill with the real module locations (it will live
under the feature-sliced structure, e.g. @/shared/... or a feature's model/).
Tracking click events (preferred approach)
For click events on interactive elements (<button>, <a>, <summary>, or elements with role="button" / role="link"), use the tracking() helper to attach data attributes. A document-level click listener (useClickTracking) automatically fires the analytics event — no manual track() call needed.
import { tracking } from "@/utils/tracking";
<Button onClick={handleSave} {...tracking("pipeline_editor.save_pipeline")}>
Save
</Button>;
The listener appends .click to the identifier automatically, so "pipeline_editor.save_pipeline" fires as "pipeline_editor.save_pipeline.click".
With metadata
Pass a second argument for event-specific properties. The metadata is serialized as a data-tracking-metadata JSON attribute on the DOM element.
<Button
onClick={() => handleLayout(algo)}
{...tracking("pipeline_canvas.tool_bar.auto_layout_select", {
selected_layout: algo,
page_type: "pipeline_editor",
})}
>
{algo}
</Button>
Prop drilling for click tracking
When a child component renders the interactive element but the parent owns the tracking context, prefer prop drilling or forwarding rest props so data-tracking-id reaches the DOM element. This is intentional — it keeps tracking declarative and avoids manual track() calls scattered across the codebase.
<ActionButton
tooltip="Export Pipeline"
icon="FileDown"
onClick={handleExport}
{...tracking("pipeline_editor.pipeline_actions.export_pipeline")}
/>;
export const ActionButton = ({
tooltip,
onClick,
icon,
...rest
}: ActionButtonProps) => (
<TooltipButton onClick={onClick} tooltip={tooltip} {...rest}>
<Icon name={icon} />
</TooltipButton>
);
For components that wrap interactive elements (e.g. Radix asChild patterns), spread tracking() on the wrapper — Radix merges props onto the child:
<DialogTrigger
asChild
{...tracking("pipeline_editor.task_node.component_info")}
>
<InfoIconButton />
</DialogTrigger>
Dynamic metadata at render time
When metadata depends on component state that is known at render time, compute it inline. The data attribute updates on each render:
<Button
onClick={() => toggleFavorite()}
{...(analyticsActionType
? tracking(analyticsActionType, { new_value: !active })
: {})}
>
When to use manual track() instead
Use useAnalytics and call track() directly only when the document-level click listener cannot handle the scenario:
| Scenario | Why manual | Example |
|---|
| Outcome events | Fire after an async operation succeeds, not on click | track("pipeline_editor.pipeline_actions.save_pipeline_as_completed") |
| Impression events | Fire when something becomes visible (dialog, panel), not from a click | track("pipeline_editor.name_pipeline_dialog_impression") |
| Debounced events | Fire after a delay (e.g. text field editing) | debouncedTrack() using debounce from @/utils/debounce |
| Toggle state | Metadata includes the new value from an onChange callback argument, not known at render time | track("settings.toggle_changed", { new_value: checked }) |
| Non-interactive elements | The click target is not a <button>, <a>, <summary>, or role="button"/role="link" | React Flow's internal Controls callbacks (onZoomIn, etc.) |
import { useAnalytics } from "@/providers/AnalyticsProvider";
const { track } = useAnalytics();
const handleSave = async (name: string) => {
await savePipeline(name);
track("pipeline_editor.pipeline_actions.save_pipeline_as_completed");
};
useEffect(() => {
if (open) {
track("component_editor.save.already_exists_impression");
}
}, [open]);
Event detail shape
The dispatched CustomEvent carries the following detail fields:
| Field | Type | Description |
|---|
actionType | string | The action type string (with .click appended for click events) |
metadata | Record<string,unknown> | Optional metadata object |
sessionId | string | Anonymous tab session ID |
route | string | window.location.pathname at time of call |
appVersion | string | undefined | VITE_GIT_COMMIT build variable |
environment | string | undefined | VITE_TANGLE_ENV build variable |
action_type naming convention
Use dot-separated, snake_case segments:
<feature_area>.<entity>[.<sub_entity>].<action_verb>
For tracking() data attributes, omit the action verb — the listener appends .click:
<feature_area>.<entity>[.<sub_entity>]
For manual track() calls, include the full action verb:
<feature_area>.<entity>[.<sub_entity>].<action_verb>
Action verb reference
| Verb | When to use |
|---|
click | User explicitly clicked or tapped a button/link (auto-appended by click listener) |
impression | Something became visible for the first time in a session (dialog opened, panel shown) |
completed | An async operation finished successfully |
toggle | User toggled a switch or checkbox |
Rules
- All segments are
snake_case — no camelCase, no hyphens.
- The last segment must always be an action verb for manual
track() calls.
- For
tracking() helper calls, the last segment is the entity — .click is appended automatically.
- Keep hierarchy shallow — prefer
pipeline.component over pipeline.canvas.node.component.
- Do not embed counts, IDs, or dynamic values in the
action_type string. Put them in metadata instead.
Examples
# tracking() helper (click listener appends .click)
tracking("header.settings") → header.settings.click
tracking("pipeline_editor.task_node.z_index", { action: "move_forward" })
→ pipeline_editor.task_node.z_index.click
# Manual track() calls
track("pipeline_editor.pipeline_actions.save_pipeline_as_completed") ✓ outcome
track("pipeline_editor.name_pipeline_dialog_impression") ✓ impression
track("settings.toggle_changed", { flag_name: "dashboard" }) ✓ toggle
track("session.tab.start", { flags: { ... } }) ✓ lifecycle
# Bad
track("header.settings_click") ✗ use tracking() helper instead
track("pipeline.run.submit.clicked") ✗ "clicked" is redundant
Metadata
Pass an object as the second argument for event-specific properties. Keys should be snake_case. Values must never contain PII (no emails, names, user IDs, or free-form user input).
tracking("pipeline_canvas.tool_bar.auto_layout_select", {
selected_layout: "sugiyama",
page_type: "pipeline_editor",
});
track("settings.secrets.secret_mutated", { action: "created" });
Component events
Two namespaces are reserved for the Component Marketplace telemetry baseline:
pipeline_editor.component.* — canvas interactions for a specific component instance (drop, replace, remove, upgrade, duplicate).
component_library.* — discovery and library CRUD (search, row click, add, remove, future publish/deprecate).
Standard metadata for component events
Spread componentMetadata(ref, source) from @/utils/componentTracking so every event carries the same identity fields:
import { componentMetadata } from "@/utils/componentTracking";
track("pipeline_editor.component.dropped", {
...componentMetadata(componentRef, "library"),
drop_kind: "new_node",
pipeline_id: pipelineId,
});
| Key | Type | Notes |
|---|
component_id | string | undefined | Content-addressed digest. Stable across renames. |
component_name | string | undefined | Human-readable name. |
component_source | "user" | "library" | "published" | "url" | "file" | "unknown" | Where the component came from. |
Library mutation events fire from the provider
component_library.added and component_library.removed are emitted from inside ComponentLibraryProvider — callers do not call track() for them. Instead, pass an entryPoint string so the provider can attribute which UI surface triggered the mutation:
await addToComponentLibrary(hydratedComponent, "favorite_button");
await removeFromComponentLibrary(component, "favorite_button");
Valid entryPoint values: "favorite_button", "canvas_file_drop", "canvas_file_drop_v2", "import_dialog", "editor_save", "unknown".
Reserved names (do not use yet)
These are documented so future PRs don't collide with the marketplace taxonomy:
component_library.published, component_library.deprecated, component_library.superseded
component_library.shared_link.copied
component_collection.created, component_collection.viewed, component_collection.member_added
component_library.search.result.semantic_match