[eric] unify telemetry surface, install_method, frontend trackEvent

This commit is contained in:
ciregenz
2026-05-05 14:35:43 -07:00
parent 49c649e3d9
commit 415ab70b53
20 changed files with 372 additions and 466 deletions
-13
View File
@@ -1,13 +0,0 @@
// Legacy shim. Forwards to serviceClient so every existing trackEvent()
// call site routes through the cloud relay without churning ~50 call
// sites across the frontend. Deleted entirely when those call sites
// migrate (or sooner — both paths run cleanly).
//
// New code should import from '@/shared/serviceClient' directly.
export {
trackEvent,
getLastAction,
getLastPage,
getTimeSpent,
} from './serviceClient';
+7 -7
View File
@@ -4,7 +4,7 @@ import { activateSubscription } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
@@ -37,7 +37,7 @@ export function useDeepLink(): void {
const plan = url.searchParams.get('plan');
const expires = url.searchParams.get('expires');
trackEvent('subscription.deep_link_received', {
report('subscription', 'deep_link_received', {
plan: plan ?? 'unknown',
});
@@ -50,14 +50,14 @@ export function useDeepLink(): void {
)
.unwrap()
.then((res) => {
trackEvent('subscription.activated', { plan: res.plan });
report('subscription', 'activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
dispatch(fetchModels());
})
.catch((err) => {
console.error('[deep-link] Activation failed:', err);
trackEvent('subscription.activation_failed', {
report('subscription', 'activation_failed', {
message: String(err).slice(0, 120),
});
});
@@ -86,7 +86,7 @@ export function useDeepLink(): void {
return;
}
trackEvent('oauth.deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
report('oauth', 'deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
const resp = await fetch(`${API_BASE}/tools/oauth/claim`, {
method: 'POST',
@@ -96,10 +96,10 @@ export function useDeepLink(): void {
if (!resp.ok) {
const text = await resp.text();
console.error('[deep-link] OAuth claim failed:', resp.status, text);
trackEvent('oauth.claim_failed', { status: resp.status });
report('oauth', 'claim_failed', { status: resp.status });
return;
}
trackEvent('oauth.claim_succeeded');
report('oauth', 'claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
+21 -23
View File
@@ -41,26 +41,33 @@ export function sync(data: Record<string, unknown> = {}, opts: { immediate?: boo
}
}
/** Backwards-compat shim — maps legacy trackEvent calls to sync(). */
export function trackEvent(
eventType: string,
properties?: Record<string, unknown>,
useBeacon = false,
/**
* Compact ship-an-event helper. Produces the same wire shape as `sync()`
* — `{ s: surface, a: action, p: props }` — but reads as a "report a UI
* surface event" verb in caller code rather than a free-form state dump.
*
* The cloud reads (surface, action) tuples from the opaque payload and
* decides what they mean. The desktop never names what it's reporting.
*/
export function report(
surface: string,
action: string,
props?: Record<string, unknown>,
opts: { immediate?: boolean } = {},
): void {
const dot = eventType.indexOf('.');
const surface = dot > 0 ? eventType.slice(0, dot) : eventType;
const action = dot > 0 ? eventType.slice(dot + 1) : 'fired';
sync(
{ s: surface, a: action, p: properties || {} },
{ immediate: useBeacon },
);
sync({ s: surface, a: action, p: props || {} }, opts);
}
export function getSessionTraceState(): {
appStartTs: number;
lastTs: number;
currentPage: string;
} {
return { appStartTs: _appStart, lastTs: _lastTs };
return {
appStartTs: _appStart,
lastTs: _lastTs,
currentPage: typeof window === 'undefined' ? '' : (window.location.hash || window.location.pathname),
};
}
export function _resetForTest(): void {
@@ -73,14 +80,5 @@ export function _resetForTest(): void {
_lastTs = _appStart;
}
export function getLastAction(): string { return ''; }
export function getLastPage(): string {
if (typeof window === 'undefined') return '';
return window.location.hash || window.location.pathname;
}
export function getTimeSpent(): number {
return Math.round((Date.now() - _appStart) / 1000);
}
const serviceClient = { sync, trackEvent, getSessionTraceState };
const serviceClient = { sync, report, getSessionTraceState };
export default serviceClient;
@@ -1,82 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const ANALYTICS_API = `${API_BASE}/service`;
export interface UsageSummary {
total_sessions: number;
total_cost_usd: number;
total_messages: number;
total_tool_calls: number;
avg_duration_seconds: number;
avg_cost_per_session: number;
completion_rate: number;
models_used: Record<string, number>;
providers_used: Record<string, number>;
top_tools: Record<string, number>;
status_breakdown: Record<string, number>;
// 9Router enrichment
total_prompt_tokens: number;
total_completion_tokens: number;
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
cost_by_provider: Record<string, { cost: number; requests: number }>;
cost_source: ' 9router' | 'sdk' | 'none';
nine_router_available: boolean;
total_requests: number;
}
export interface CostBreakdown {
available: boolean;
period: string;
total_cost: number;
total_requests: number;
total_prompt_tokens: number;
total_completion_tokens: number;
by_model: Record<string, any>;
by_provider: Record<string, any>;
}
interface AnalyticsState {
summary: UsageSummary | null;
costBreakdown: CostBreakdown | null;
loading: boolean;
}
const initialState: AnalyticsState = {
summary: null,
costBreakdown: null,
loading: false,
};
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
return (await res.json()) as UsageSummary;
});
export const fetchCostBreakdown = createAsyncThunk(
'analytics/fetchCostBreakdown',
async (period: string = '7d') => {
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
return (await res.json()) as CostBreakdown;
},
);
const analyticsSlice = createSlice({
name: 'analytics',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; })
.addCase(fetchAnalyticsSummary.fulfilled, (state, action) => {
state.loading = false;
state.summary = action.payload;
})
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
state.costBreakdown = action.payload;
});
},
});
export default analyticsSlice.reducer;
-2
View File
@@ -11,7 +11,6 @@ import outputsReducer from './outputsSlice';
import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import analyticsReducer from './analyticsSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
@@ -29,7 +28,6 @@ export const store = configureStore({
dashboardLayout: dashboardLayoutReducer,
dashboards: dashboardsReducer,
update: updateReducer,
analytics: analyticsReducer,
models: modelsReducer,
interaction: interactionReducer,
},
+5 -5
View File
@@ -1,4 +1,4 @@
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
export type OpenSwarmPlan = 'pro' | 'pro_plus' | 'ultra';
export type BillingInterval = 'monthly' | 'annual';
@@ -11,14 +11,14 @@ interface SubscribeOptions {
// Kicks off a Stripe Checkout session for the given plan + interval and opens
// the returned URL in the user's default browser (or a new tab fallback).
// All subscribe CTAs across Settings, Onboarding, and the 429 error card go
// through this helper so analytics shape and error handling stay consistent.
// through this helper so the wire shape and error handling stay consistent.
export async function subscribeToPlan(
plan: OpenSwarmPlan,
billingInterval: BillingInterval,
source: CheckoutSource,
opts: SubscribeOptions = {},
): Promise<void> {
trackEvent('subscription.subscribe_clicked', {
report('subscription', 'subscribe_clicked', {
source,
plan,
billing_interval: billingInterval,
@@ -26,7 +26,7 @@ export async function subscribeToPlan(
});
try {
// Cloud schema uses "yearly"; the desktop UI/analytics uses "annual".
// Cloud schema uses "yearly"; the desktop UI uses "annual".
// Normalize at the boundary so the rest of the client stays consistent.
const wireInterval = billingInterval === 'annual' ? 'yearly' : billingInterval;
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
@@ -41,7 +41,7 @@ export async function subscribeToPlan(
const { url } = await r.json();
if (!url) return;
trackEvent('subscription.checkout_opened', {
report('subscription', 'checkout_opened', {
source,
plan,
billing_interval: billingInterval,