Add tracking for Automations, Plugin Detail & Loop (#3103)

* Add tracking for Automations, Plugin Detail, and Plugin Loop

- RoutinesSection: track new_automation, create, save, cancel, run_now,
  edit, pause, resume, delete, history button clicks
- PluginDetailView: track back navigation and use_plugin action
- PluginLoopHome: track clear_active, submit, card_details, card_use
- Extend AutomationsClickProps with new CRUD elements
- Add PluginDetailClickProps and PluginLoopClickProps contracts

* fix: address review comments on plugin/automation tracking

- Extract onBack handler in PluginDetailView to cover both error-path
  and success-path back buttons with tracking
- Move create/save tracking from submit button onClick into the form
  submit handler to capture keyboard submissions and avoid false
  positives from validation failures

* fix: move submit tracking into submit() handler in PluginLoopHome

Same fix as RoutinesSection: tracking now fires inside submit() so
keyboard Enter submissions are captured and the !trimmed guard
prevents false positives.

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
elihahah666 2026-05-28 00:48:41 +08:00 committed by GitHub
parent 279da4f3b6
commit dadf8a5bc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 73 additions and 10 deletions

View file

@ -36,6 +36,8 @@ import type {
PluginsTemplatesDropdownClickProps, PluginsTemplatesDropdownClickProps,
PluginsAvailableTabClickProps, PluginsAvailableTabClickProps,
PluginsSourcesTabClickProps, PluginsSourcesTabClickProps,
PluginDetailClickProps,
PluginLoopClickProps,
DesignSystemsTopClickProps, DesignSystemsTopClickProps,
DesignSystemsTemplateCardClickProps, DesignSystemsTemplateCardClickProps,
DesignSystemsTemplatesModalClickProps, DesignSystemsTemplatesModalClickProps,
@ -326,6 +328,20 @@ export function trackPluginsSourcesTabClick(
send(track, 'ui_click', props); send(track, 'ui_click', props);
} }
export function trackPluginDetailClick(
track: Track,
props: PluginDetailClickProps,
): void {
send(track, 'ui_click', props);
}
export function trackPluginLoopClick(
track: Track,
props: PluginLoopClickProps,
): void {
send(track, 'ui_click', props);
}
export function trackDesignSystemsTopClick( export function trackDesignSystemsTopClick(
track: Track, track: Track,
props: DesignSystemsTopClickProps, props: DesignSystemsTopClickProps,

View file

@ -12,6 +12,8 @@ import type { ApplyResult, InstalledPluginRecord } from '@open-design/contracts'
import { applyPlugin } from '../state/projects'; import { applyPlugin } from '../state/projects';
import { navigate } from '../router'; import { navigate } from '../router';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import { useAnalytics } from '../analytics/provider';
import { trackPluginDetailClick } from '../analytics/events';
interface Props { interface Props {
pluginId: string; pluginId: string;
@ -19,11 +21,17 @@ interface Props {
export function PluginDetailView(props: Props) { export function PluginDetailView(props: Props) {
const { locale } = useI18n(); const { locale } = useI18n();
const analytics = useAnalytics();
const [plugin, setPlugin] = useState<InstalledPluginRecord | null>(null); const [plugin, setPlugin] = useState<InstalledPluginRecord | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const [applied, setApplied] = useState<ApplyResult | null>(null); const [applied, setApplied] = useState<ApplyResult | null>(null);
const onBack = () => {
trackPluginDetailClick(analytics.track, { page_name: 'plugins', area: 'plugin_detail', element: 'back', plugin_id: props.pluginId });
navigate({ kind: 'marketplace' });
};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void fetch(`/api/plugins/${encodeURIComponent(props.pluginId)}`) void fetch(`/api/plugins/${encodeURIComponent(props.pluginId)}`)
@ -47,7 +55,7 @@ export function PluginDetailView(props: Props) {
if (error) { if (error) {
return ( return (
<div className="plugin-detail" data-testid="plugin-detail"> <div className="plugin-detail" data-testid="plugin-detail">
<button type="button" onClick={() => navigate({ kind: 'marketplace' })}> <button type="button" onClick={onBack}>
Marketplace Marketplace
</button> </button>
<div role="alert">Failed to load plugin: {error}</div> <div role="alert">Failed to load plugin: {error}</div>
@ -80,6 +88,7 @@ export function PluginDetailView(props: Props) {
}>; }>;
const onUse = async () => { const onUse = async () => {
trackPluginDetailClick(analytics.track, { page_name: 'plugins', area: 'plugin_detail', element: 'use_plugin', plugin_id: plugin.id });
setApplying(true); setApplying(true);
setError(null); setError(null);
const result = await applyPlugin(plugin.id, { locale }); const result = await applyPlugin(plugin.id, { locale });
@ -100,7 +109,7 @@ export function PluginDetailView(props: Props) {
<button <button
type="button" type="button"
className="plugin-detail__back" className="plugin-detail__back"
onClick={() => navigate({ kind: 'marketplace' })} onClick={onBack}
> >
Marketplace Marketplace
</button> </button>

View file

@ -15,6 +15,8 @@ import { Icon } from './Icon';
import { PluginDetailsModal } from './PluginDetailsModal'; import { PluginDetailsModal } from './PluginDetailsModal';
import { TrustBadge } from './TrustBadge'; import { TrustBadge } from './TrustBadge';
import { authorInitials, derivePluginSourceLinks } from '../runtime/plugin-source'; import { authorInitials, derivePluginSourceLinks } from '../runtime/plugin-source';
import { useAnalytics } from '../analytics/provider';
import { trackPluginLoopClick } from '../analytics/events';
export interface PluginLoopSubmit { export interface PluginLoopSubmit {
prompt: string; prompt: string;
@ -56,6 +58,7 @@ interface ActivePlugin {
export function PluginLoopHome({ onSubmit }: Props) { export function PluginLoopHome({ onSubmit }: Props) {
const { locale } = useI18n(); const { locale } = useI18n();
const analytics = useAnalytics();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]); const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null); const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
@ -128,6 +131,7 @@ export function PluginLoopHome({ onSubmit }: Props) {
function submit() { function submit() {
const trimmed = prompt.trim(); const trimmed = prompt.trim();
if (!trimmed) return; if (!trimmed) return;
trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'submit', plugin_id: active?.record.id });
onSubmit({ onSubmit({
prompt: trimmed, prompt: trimmed,
pluginId: active?.record.id ?? null, pluginId: active?.record.id ?? null,
@ -168,7 +172,7 @@ export function PluginLoopHome({ onSubmit }: Props) {
<button <button
type="button" type="button"
className="plugin-loop-home__active-clear" className="plugin-loop-home__active-clear"
onClick={clearActive} onClick={() => { trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'clear_active', plugin_id: active?.record.id }); clearActive(); }}
aria-label="Clear active plugin" aria-label="Clear active plugin"
title="Clear active plugin" title="Clear active plugin"
> >
@ -293,7 +297,7 @@ export function PluginLoopHome({ onSubmit }: Props) {
<button <button
type="button" type="button"
className="plugin-loop-home__card-details" className="plugin-loop-home__card-details"
onClick={() => openDetails(p)} onClick={() => { trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'card_details', plugin_id: p.id }); openDetails(p); }}
aria-label={`View details for ${p.title}`} aria-label={`View details for ${p.title}`}
data-testid={`view-details-${p.id}`} data-testid={`view-details-${p.id}`}
title="View plugin details" title="View plugin details"
@ -304,7 +308,7 @@ export function PluginLoopHome({ onSubmit }: Props) {
<button <button
type="button" type="button"
className="plugin-loop-home__card-action" className="plugin-loop-home__card-action"
onClick={() => void usePlugin(p)} onClick={() => { trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'card_use', plugin_id: p.id }); void usePlugin(p); }}
disabled={isPending || pendingApplyId !== null} disabled={isPending || pendingApplyId !== null}
aria-busy={isPending ? 'true' : undefined} aria-busy={isPending ? 'true' : undefined}
data-testid={`use-example-${p.id}`} data-testid={`use-example-${p.id}`}

View file

@ -13,6 +13,8 @@ import { Icon } from './Icon';
import { navigate } from '../router'; import { navigate } from '../router';
import { useT } from '../i18n'; import { useT } from '../i18n';
import type { Dict } from '../i18n/types'; import type { Dict } from '../i18n/types';
import { useAnalytics } from '../analytics/provider';
import { trackAutomationsClick } from '../analytics/events';
// Shared translator signature: every sub-component in this file is module-scoped, // Shared translator signature: every sub-component in this file is module-scoped,
// so `t` from `useT()` is threaded down as a prop rather than re-hooked. // so `t` from `useT()` is threaded down as a prop rather than re-hooked.
@ -457,6 +459,10 @@ function RunHistory({
export function RoutinesSection({ onClose }: RoutinesSectionProps) { export function RoutinesSection({ onClose }: RoutinesSectionProps) {
const t = useT(); const t = useT();
const analytics = useAnalytics();
const fireAutomation = (element: 'new_automation' | 'create' | 'save' | 'cancel' | 'run_now' | 'edit' | 'pause' | 'resume' | 'delete' | 'history') => {
trackAutomationsClick(analytics.track, { page_name: 'automations', area: 'automations', element });
};
const [routines, setRoutines] = useState<Routine[]>([]); const [routines, setRoutines] = useState<Routine[]>([]);
const [projects, setProjects] = useState<ProjectSummary[]>([]); const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -515,6 +521,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
fireAutomation(editingId ? 'save' : 'create');
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
@ -625,6 +632,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => {
fireAutomation('new_automation');
setForm(emptyForm()); setForm(emptyForm());
setShowForm(true); setShowForm(true);
}} }}
@ -715,6 +723,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
type="button" type="button"
className="btn" className="btn"
onClick={() => { onClick={() => {
fireAutomation('cancel');
setShowForm(false); setShowForm(false);
setEditingId(null); setEditingId(null);
setForm(emptyForm()); setForm(emptyForm());
@ -789,7 +798,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={() => runNow(r.id)} onClick={() => { fireAutomation('run_now'); runNow(r.id); }}
disabled={isBusy} disabled={isBusy}
> >
{t('routines.runNow')} {t('routines.runNow')}
@ -798,6 +807,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
type="button" type="button"
className="btn" className="btn"
onClick={() => { onClick={() => {
fireAutomation('edit');
setForm(formFromRoutine(r)); setForm(formFromRoutine(r));
setEditingId(r.id); setEditingId(r.id);
setShowForm(true); setShowForm(true);
@ -809,7 +819,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
<button <button
type="button" type="button"
className="btn" className="btn"
onClick={() => toggleEnabled(r)} onClick={() => { fireAutomation(r.enabled ? 'pause' : 'resume'); toggleEnabled(r); }}
disabled={isBusy} disabled={isBusy}
> >
{r.enabled ? t('routines.pause') : t('routines.resume')} {r.enabled ? t('routines.pause') : t('routines.resume')}
@ -817,7 +827,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
onClick={() => setExpandedId(isExpanded ? null : r.id)} onClick={() => { fireAutomation('history'); setExpandedId(isExpanded ? null : r.id); }}
aria-expanded={isExpanded} aria-expanded={isExpanded}
> >
{isExpanded ? t('routines.hideHistory') : t('routines.history')} {isExpanded ? t('routines.hideHistory') : t('routines.history')}
@ -825,7 +835,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
<button <button
type="button" type="button"
className="btn btn-ghost btn-danger" className="btn btn-ghost btn-danger"
onClick={() => remove(r.id)} onClick={() => { fireAutomation('delete'); remove(r.id); }}
disabled={isBusy} disabled={isBusy}
title={t('routines.deleteTitle')} title={t('routines.deleteTitle')}
> >

View file

@ -1016,7 +1016,15 @@ export interface AutomationsClickProps {
| 'run_now' | 'run_now'
| 'open_artifact' | 'open_artifact'
| 'type_card' | 'type_card'
| 'filter_tab'; | 'filter_tab'
| 'edit'
| 'pause'
| 'resume'
| 'delete'
| 'history'
| 'cancel'
| 'create'
| 'save';
type_id?: 'orbit' | 'routines' | 'schedules' | 'live_artifacts'; type_id?: 'orbit' | 'routines' | 'schedules' | 'live_artifacts';
filter_id?: 'all' | 'scheduled' | 'running' | 'done'; filter_id?: 'all' | 'scheduled' | 'running' | 'done';
} }
@ -1078,6 +1086,20 @@ export interface PluginsSourcesTabClickProps {
plugin_type?: string; plugin_type?: string;
} }
export interface PluginDetailClickProps {
page_name: 'plugins';
area: 'plugin_detail';
element: 'back' | 'use_plugin';
plugin_id?: string;
}
export interface PluginLoopClickProps {
page_name: 'plugins';
area: 'plugin_loop';
element: 'clear_active' | 'submit' | 'card_details' | 'card_use';
plugin_id?: string;
}
// DESIGN SYSTEMS // DESIGN SYSTEMS
export interface DesignSystemsTopClickProps { export interface DesignSystemsTopClickProps {
page_name: 'design_systems'; page_name: 'design_systems';
@ -1490,6 +1512,8 @@ export type UiClickProps =
| PluginsTemplatesDropdownClickProps | PluginsTemplatesDropdownClickProps
| PluginsAvailableTabClickProps | PluginsAvailableTabClickProps
| PluginsSourcesTabClickProps | PluginsSourcesTabClickProps
| PluginDetailClickProps
| PluginLoopClickProps
| DesignSystemsTopClickProps | DesignSystemsTopClickProps
| DesignSystemsTemplateCardClickProps | DesignSystemsTemplateCardClickProps
| DesignSystemsTemplatesModalClickProps | DesignSystemsTemplatesModalClickProps