mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[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:
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user