[haik]: replace OPENSWARM_TIMEZONE/OPENSWARM_LOCALE env-var injection with renderer-reported browser Intl values: frontend reports tz+locale on launch via reportAppOpened, backend persists them in AppSettings (new timezone/locale fields, server-owned), and new resolve_timezone/resolve_locale helpers in client.py provide a single fallback chain (settings -> tzlocal -> datetime) used by the analytics envelope, agent wall-clock context, and new app_lifecycle.opened/closed events; removes the Electron env vars, the startup diagnostic log write, and the analytics URL env override so all builds behave identically

This commit is contained in:
haikdc
2026-06-20 14:37:55 -07:00
parent 10d5260876
commit b8fec9de62
10 changed files with 213 additions and 72 deletions
+7 -9
View File
@@ -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 = (
+75 -4
View File
@@ -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)
+58 -32
View File
@@ -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).
+23 -14
View File
@@ -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")
+7
View File
@@ -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
+2
View File
@@ -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",
+3 -3
View File
@@ -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).
+6 -8
View File
@@ -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
+6 -1
View File
@@ -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());
+26 -1
View File
@@ -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;