diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 9598cee9..99ce3d4c 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1075,15 +1075,13 @@ class AgentManager: # so it can answer day-of-week questions without hallucinating. try: from zoneinfo import ZoneInfo - # Best-effort IANA name for the host. Mirrors apps/service/client.py. - tz_name = os.environ.get("OPENSWARM_TIMEZONE", "").strip() - if not tz_name: - try: - from tzlocal import get_localzone_name # type: ignore - tz_name = get_localzone_name() or "" - except Exception: - tz_name = "" - tz_name = tz_name or "UTC" + # IANA zone via the shared resolver (renderer-reported value + # persisted in settings, then tzlocal / datetime fallbacks). Same + # source the analytics envelope uses, so the agent's clock and + # telemetry never disagree, and it resolves identically in + # packaged, dev, and open-source runs (no env-var dependency). + from backend.apps.service.client import resolve_timezone + tz_name = resolve_timezone() or "UTC" now_local = datetime.now(ZoneInfo(tz_name)) tz_abbr = now_local.strftime("%Z") or tz_name time_ctx = ( diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics.py index 531df874..eff0219b 100644 --- a/backend/apps/service/analytics.py +++ b/backend/apps/service/analytics.py @@ -8,7 +8,7 @@ never break the app. See ANALYTICS_OVERVIEW.md for the SDK contract. from __future__ import annotations import logging -import os +import platform from typing import Optional from swarm_analytics import AnalyticsClient @@ -19,9 +19,10 @@ P_CLIENT: Optional[AnalyticsClient] = None def p_base_url() -> str: - # The analytics service must NOT share the desktop backend's port (8324). - # Default points at the local analytics service; override per environment. - return os.environ.get("OPENSWARM_ANALYTICS_URL", "http://127.0.0.1:6792").rstrip("/") + # One fixed analytics endpoint for every build (dev, packaged, OSS) so the + # analytics handling is identical everywhere. Must NOT share the desktop + # backend's port (8324); the analytics service listens on 6792. + return "http://127.0.0.1:6792" def p_mode() -> str: @@ -126,3 +127,73 @@ def track_onboarding_step(*, step_id: str, status: str) -> None: c.events.onboarding.step(step_id=step_id, status=status) except Exception as e: logger.debug("analytics onboarding.step failed: %s", e) + + +# app_lifecycle.opened is fired at most once per backend process. The renderer +# triggers it (so it carries the browser's canonical tz/locale, the only source +# that works for packaged, dev, AND open-source runs), but a renderer can remount +# or hard-reload many times against one long-lived backend — especially in dev — +# so this process-scoped guard is what actually enforces one event per app launch. +P_OPENED_FIRED = False + + +def persist_client_env(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: + """Store the renderer-reported tz/locale so the cloud envelope (stamped on + every submission via client.resolve_*) can use them on dev / open-source runs + where Electron's env injection never happens. Overwrites every launch, so a + user who changed timezone since last open reports the new one. Writes to disk + only when a value actually changed, to avoid settings churn each launch.""" + tz = (timezone or "").strip() or None + loc = (locale or "").strip() or None + if tz is None and loc is None: + return + try: + from backend.apps.settings.store import load_settings, save_settings + s = load_settings() + changed = False + if tz and getattr(s, "timezone", None) != tz: + s.timezone = tz + changed = True + if loc and getattr(s, "locale", None) != loc: + s.locale = loc + changed = True + if changed: + save_settings(s) + except Exception as e: + logger.debug("analytics persist_client_env failed: %s", e) + + +def track_app_opened(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: + """Fire app_lifecycle.opened once per backend process. tz/locale come from the + renderer (browser Intl); os/version are filled in here. Falls back to the + shared resolver only if the caller passed nothing (defensive; the renderer + path always supplies both).""" + global P_OPENED_FIRED + if P_OPENED_FIRED: + return + c = get_analytics_client() + if c is None: + return + try: + from backend.apps.service.version import APP_VERSION + from backend.apps.service.client import resolve_timezone, resolve_locale + c.events.app_lifecycle.opened( + os=platform.system(), + os_version=platform.release(), + app_version=APP_VERSION, + timezone=timezone if timezone is not None else resolve_timezone(), + locale=locale if locale is not None else resolve_locale(), + ) + P_OPENED_FIRED = True + except Exception as e: + logger.debug("analytics app_lifecycle.opened failed: %s", e) + + +def track_app_closed() -> None: + c = get_analytics_client() + if c is None: + return + try: + c.events.app_lifecycle.closed() + except Exception as e: + logger.debug("analytics app_lifecycle.closed failed: %s", e) diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index 15cb6613..1edf76e7 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -121,6 +121,58 @@ def p_is_enabled(kind: str) -> bool: return True +def resolve_timezone() -> Optional[str]: + """Canonical IANA zone name for the envelope + app_lifecycle events. + + Identical in every build (dev, packaged, open-source). Precedence: + 1. Renderer-reported value persisted in settings — the browser Intl zone + the frontend sends every launch. + 2. Python local-zone fallbacks (tzlocal, then datetime tzinfo), which can + return abbreviations ("PDT") or localized names that don't round-trip + through tzdata — last resort so very-early-startup submissions (before + the renderer has reported) still carry something. + """ + try: + from backend.apps.settings.store import load_settings + tz = (getattr(load_settings(), "timezone", None) or "").strip() + if tz: + return tz + except Exception: + pass + try: + from tzlocal import get_localzone_name # type: ignore + tz = get_localzone_name() or "" + if tz: + return tz + except Exception: + pass + try: + import datetime as dt + local_tz = dt.datetime.now().astimezone().tzinfo + if local_tz: + return str(local_tz) + except Exception: + pass + return None + + +def resolve_locale() -> Optional[str]: + """BCP 47 locale ("en-US", "es-ES", ...) for the envelope + app_lifecycle + events. Identical in every build: renderer-reported value persisted in + settings -> None. No Python fallback: locale.getdefaultlocale() is + deprecated, often empty, and inconsistent across OSes, so an absent value is + better than a wrong one. + """ + try: + from backend.apps.settings.store import load_settings + loc = (getattr(load_settings(), "locale", None) or "").strip() + if loc: + return loc + except Exception: + pass + return None + + def p_envelope() -> dict: """Identity + environment metadata stamped on every submission.""" env: dict[str, Any] = {"install_id": p_get_install_id()} @@ -133,38 +185,12 @@ def p_envelope() -> dict: env["device_type"] = "desktop" except Exception: pass - # Timezone: prefer the IANA zone name passed in by Electron (always - # canonical, e.g. "America/Los_Angeles") so cloud-side localTimeFields() - # can format hour-of-day correctly. Fall back to Python's local zone - # which sometimes returns abbreviations (PDT, CDT) or localized names - # ("Romance (zomertijd)") that don't round-trip through tzdata. - try: - ianatz = os.environ.get("OPENSWARM_TIMEZONE", "").strip() - if not ianatz: - try: - from tzlocal import get_localzone_name # type: ignore - ianatz = get_localzone_name() or "" - except Exception: - pass - if not ianatz: - import datetime as dt - local_tz = dt.datetime.now().astimezone().tzinfo - if local_tz: - ianatz = str(local_tz) - if ianatz: - env["timezone"] = ianatz - except Exception: - pass - # Locale: BCP 47 string ("en-US", "es-ES", etc.) injected by Electron via - # app.getLocale(); see electron/main.js. We don't fall back to Python's - # locale.getdefaultlocale() because that's deprecated, often empty, and - # returns inconsistent OS-specific values across macOS/Windows/Linux. - try: - loc = os.environ.get("OPENSWARM_LOCALE", "").strip() - if loc: - env["locale"] = loc - except Exception: - pass + tz = resolve_timezone() + if tz: + env["timezone"] = tz + loc = resolve_locale() + if loc: + env["locale"] = loc env["app_version"] = APP_VERSION # How this build was packaged. Set by the platform-specific build script # (electron-builder afterPack hooks for dmg / exe / appimage / deb / rpm). diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index b32835b2..3f9abe67 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -184,19 +184,16 @@ async def service_lifespan(): sync({"identity": id_props}) - # swarm-analytics: bootstrap the client (registers + persists a token on - # first run) and prove the pipe with a single diagnostic log write. - from backend.apps.service.analytics import get_analytics_client, track_link_email - client = get_analytics_client() - if client is not None: - client.logs.write( - tag="app", - subtag="backend_started", - data={"app_version": APP_VERSION}, - ) - # Re-assert the email link every boot so users already signed in before - # this version shipped get linked without re-authing. Idempotent server- - # side; no-ops if no email or the client failed to bootstrap. + # swarm-analytics: re-assert the email link every boot so users already + # signed in before this version shipped get linked without re-authing. + # Idempotent server-side; no-ops if no email or the client failed to + # bootstrap. NOTE: app_lifecycle.opened is intentionally NOT fired here — + # it's renderer-triggered (see p_bridge_to_analytics) so it carries the + # browser's canonical tz/locale, which works for packaged, dev, and + # open-source runs alike. app_lifecycle.closed stays backend-side in the + # shutdown path below, where delivery is deterministic (renderer pagehide + # is not). + from backend.apps.service.analytics import track_link_email track_link_email(getattr(settings, "user_email", None)) except Exception as e: logger.debug(f"Service startup event failed (non-critical): {e}") @@ -234,7 +231,9 @@ async def service_lifespan(): except Exception: pass - from backend.apps.service.analytics import shutdown_analytics + from backend.apps.service.analytics import track_app_closed, shutdown_analytics + # Enqueue the close event BEFORE flush/close so the worker actually drains it. + track_app_closed() shutdown_analytics() logger.info("Service shut down") @@ -454,6 +453,16 @@ def p_bridge_to_analytics(item: dict) -> None: if dashboard_id: from backend.apps.service.analytics import track_dashboard_event track_dashboard_event(dashboard_id=str(dashboard_id), action=a) + elif s == "app" and a == "opened": + # The renderer reports the browser's canonical IANA timezone + BCP 47 + # locale on launch. Persist them (overwriting last launch, so a timezone + # switch is picked up) for the cloud envelope, then emit the once-per- + # process app_lifecycle.opened carrying those exact values. + tz = p.get("timezone") if isinstance(p.get("timezone"), str) else None + loc = p.get("locale") if isinstance(p.get("locale"), str) else None + from backend.apps.service.analytics import persist_client_env, track_app_opened + persist_client_env(timezone=tz, locale=loc) + track_app_opened(timezone=tz, locale=loc) @service.router.post("/submit") diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 275045c9..55bfdf85 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -69,6 +69,13 @@ class AppSettings(BaseModel): # never re-bootstrap. Server-owned (see P_SERVER_OWNED_FIELDS) + treated as # a secret so a stale renderer PUT can't forge or wipe it. analytics_token: Optional[str] = None + # Canonical IANA timezone + BCP 47 locale reported by the renderer (browser + # Intl API) on every launch. The only source that works across packaged, dev, + # and open-source runs (Electron env injection is packaged-only). Overwritten + # each launch so a user who changes timezone reports the new one. Server-owned + # so a stale renderer settings PUT can't blank them. + timezone: Optional[str] = None + locale: Optional[str] = None first_opened_at: Optional[str] = None connection_mode: str = "own_key" openswarm_bearer_token: Optional[str] = None diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index e1deff84..91dbd507 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -123,6 +123,8 @@ P_SERVER_OWNED_FIELDS = ( "signin_method", "installation_id", "analytics_token", + "timezone", + "locale", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", diff --git a/backend/requirements.txt b/backend/requirements.txt index 73f4cf5a..c9456092 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,9 +16,9 @@ python-dotenv==1.1.1 Pillow==12.2.0 httpx==0.28.1 trafilatura==2.0.0 -# tzlocal: dev-mode fallback for resolving the user's IANA timezone when -# Electron's OPENSWARM_TIMEZONE env var isn't set (i.e. `bash run.sh`). -# Packaged builds get the env var directly so this is a safety net. +# tzlocal: fallback for resolving the user's IANA timezone before the renderer +# has reported its browser Intl zone (the primary source in every build). Used +# by service/client.py resolve_timezone() and the agent wall-clock context. tzlocal==5.3.1 # swarm-analytics: typed client for the OpenSwarm product-analytics ingest # service. Pinned for reproducible desktop builds (see ANALYTICS_OVERVIEW.md). diff --git a/electron/main.js b/electron/main.js index 79a75eaa..d42f6092 100644 --- a/electron/main.js +++ b/electron/main.js @@ -926,14 +926,12 @@ async function startBackend() { // app_version="unknown". The path-based fallback stays in place so this // change is purely additive. OPENSWARM_APP_VERSION: app.getVersion(), - // Inject the user's BCP 47 locale + IANA timezone. The Python backend - // doesn't have reliable APIs for either: locale.getdefaultlocale() is - // deprecated and inconsistent across OSes, and Python's local-tz string - // sometimes returns "PDT" or "Romance (zomertijd)" rather than - // "America/Los_Angeles". Electron has both in canonical form via - // app.getLocale() and Intl.DateTimeFormat().resolvedOptions().timeZone. - OPENSWARM_LOCALE: app.getLocale(), - OPENSWARM_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || '', + // NOTE: locale + timezone are intentionally NOT injected here. The renderer + // reports the browser's canonical Intl values to the backend on launch (see + // frontend/src/shared/serviceClient.ts reportAppOpened), which the backend + // persists and resolves identically across packaged, dev, and open-source + // runs. That single source replaced the old OPENSWARM_LOCALE/_TIMEZONE env + // injection, so adding them back here would just be dead vars. PYTHONDONTWRITEBYTECODE: '1', // PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where // the locale is otherwise cp1252. Many backend modules read UTF-8 diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 9537087c..910b407b 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -76,7 +76,7 @@ if (typeof window !== 'undefined') { if (ric) ric(prefetchAll, { timeout: 1500 }); else window.setTimeout(prefetchAll, 500); } -import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; +import { report, reportAppOpened, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; import { useRouteTracker } from '@/shared/hooks/useRouteTracker'; import { useDeepLink } from '@/shared/hooks/useDeepLink'; import { useWindowFocus } from '@/shared/hooks/useWindowFocus'; @@ -221,6 +221,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); + // Report the app launch with the browser's canonical tz/locale so the + // backend can emit analytics app_lifecycle.opened with values that work in + // packaged, dev, and open-source builds. Guarded once per page load; the + // backend dedupes per process. + reportAppOpened(); fetch(`${API_BASE}/subscription/sync`, { method: 'POST' }) .then((r) => { if (r.ok) dispatch(fetchSettings()); diff --git a/frontend/src/shared/serviceClient.ts b/frontend/src/shared/serviceClient.ts index fee2b254..780e3830 100644 --- a/frontend/src/shared/serviceClient.ts +++ b/frontend/src/shared/serviceClient.ts @@ -87,6 +87,31 @@ export function report( sync({ s: surface, a: action, p: props || {} }, opts); } +let _openedSent = false; + +/** + * Report the app launch with the browser's canonical timezone + locale (the + * Intl API gives the same values Electron does, but works in dev and the + * open-source build too, where Electron's env injection never runs). The backend + * persists these and emits analytics `app_lifecycle.opened` from them. + * + * Guarded so a remount won't re-send within one page load; the backend also + * dedupes per process, so a hard reload can't double-count an app launch. + */ +export function reportAppOpened(): void { + if (_openedSent) return; + _openedSent = true; + let timezone = ''; + let locale = ''; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { /* leave empty; backend resolver/fallback handles it */ } + try { + locale = (typeof navigator !== 'undefined' && navigator.language) || ''; + } catch { /* leave empty */ } + report('app', 'opened', { timezone, locale }, { immediate: true }); +} + export function getSessionTraceState(): { appStartTs: number; lastTs: number; @@ -99,5 +124,5 @@ export function getSessionTraceState(): { }; } -const serviceClient = { sync, report, getSessionTraceState, getRecentActions }; +const serviceClient = { sync, report, reportAppOpened, getSessionTraceState, getRecentActions }; export default serviceClient;