Skip to content

Commit 93bbd3b

Browse files
Refactor CLI command structure and improve error handling
- Restructured command files to enhance modularity and clarity in the CLI. - Improved error handling for various commands to provide more informative feedback. - Updated documentation to reflect changes in command structure and usage.
1 parent ffa86b7 commit 93bbd3b

2 files changed

Lines changed: 354 additions & 4 deletions

File tree

‎auralogger/client/__init__.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1-
"""Client-facing CLI helper exports.
2-
3-
Python package does not currently expose a browser runtime logger API.
4-
"""
1+
"""Client-side SDK and CLI helper exports."""
52

3+
from auralogger.client.client_log import (
4+
AuraClient,
5+
ClientLogInputs,
6+
auralog,
7+
client_log,
8+
close_client_log_socket,
9+
)
610
from auralogger.commands.client_check import run_client_check
711
from auralogger.commands.test_clientlog import run_test_clientlog
812

913
__all__ = [
14+
"AuraClient",
15+
"ClientLogInputs",
16+
"auralog",
17+
"client_log",
18+
"close_client_log_socket",
1019
"run_client_check",
1120
"run_test_clientlog",
1221
]

‎auralogger/client/client_log.py‎

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
"""Browser-ingest client runtime (Python parity for Node AuraClient)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import sys
7+
import threading
8+
import uuid
9+
from datetime import datetime, timezone
10+
from typing import Any, Dict, Literal, Optional
11+
12+
import websocket
13+
from pydantic import BaseModel, Field
14+
from websocket import create_connection
15+
16+
from auralogger.backend_origin import build_create_browser_logs_url, resolve_ws_base_url
17+
from auralogger.env_config import get_resolved_project_token, try_parse_resolved_styles
18+
from auralogger.log_print import print_log
19+
from auralogger.log_styles import build_style_entries_from_api
20+
from auralogger.proj_auth import fetch_proj_auth_payload
21+
22+
UNKNOWN_TYPE = "unknown"
23+
CONNECT_TIMEOUT_S = 5
24+
DEFAULT_SOCKET_IDLE_CLOSE_MS = 60_000
25+
26+
_ws: Optional[Any] = None
27+
_bound_url: Optional[str] = None
28+
_socket_idle_timer: Optional[threading.Timer] = None
29+
_local_session_id: Optional[str] = None
30+
_warned_missing_project_token = False
31+
_warned_missing_project_id = False
32+
33+
_hydrate_lock = threading.Lock()
34+
_hydration_cache_token: Optional[str] = None
35+
_hydration_cache_raw: Optional[Dict[str, Any]] = None
36+
_override_project_token: Optional[str] = None
37+
38+
39+
class ClientLogInputs(BaseModel):
40+
type: Literal["debug", "info", "warn", "error"] = "info"
41+
message: str = Field(..., min_length=1)
42+
location: Optional[str] = None
43+
data: Optional[Dict[str, Any]] = None
44+
45+
46+
def _reset_socket_idle_timer() -> None:
47+
global _socket_idle_timer
48+
if _socket_idle_timer is not None:
49+
_socket_idle_timer.cancel()
50+
_socket_idle_timer = None
51+
52+
53+
def _schedule_socket_idle_close() -> None:
54+
global _socket_idle_timer
55+
_reset_socket_idle_timer()
56+
57+
def _close_if_idle() -> None:
58+
global _socket_idle_timer
59+
_socket_idle_timer = None
60+
close_client_log_socket()
61+
62+
timer = threading.Timer(DEFAULT_SOCKET_IDLE_CLOSE_MS / 1000.0, _close_if_idle)
63+
timer.daemon = True
64+
timer.start()
65+
_socket_idle_timer = timer
66+
67+
68+
def _close_ws_connection() -> None:
69+
global _ws, _bound_url
70+
_reset_socket_idle_timer()
71+
if _ws is not None:
72+
try:
73+
_ws.close()
74+
except Exception:
75+
pass
76+
_ws = None
77+
_bound_url = None
78+
79+
80+
def close_client_log_socket() -> None:
81+
global _hydration_cache_token, _hydration_cache_raw
82+
_close_ws_connection()
83+
_hydration_cache_token = None
84+
_hydration_cache_raw = None
85+
86+
87+
def _fetch_proj_auth_cached(project_token: str) -> Optional[Dict[str, Any]]:
88+
global _hydration_cache_token, _hydration_cache_raw
89+
with _hydrate_lock:
90+
if _hydration_cache_token == project_token and _hydration_cache_raw is not None:
91+
return _hydration_cache_raw
92+
try:
93+
raw = fetch_proj_auth_payload(project_token)
94+
except ValueError:
95+
return None
96+
_hydration_cache_token = project_token
97+
_hydration_cache_raw = raw
98+
return raw
99+
100+
101+
def _resolve_project_token_runtime() -> Optional[str]:
102+
if _override_project_token is not None:
103+
s = _override_project_token.strip()
104+
if s:
105+
return s
106+
return get_resolved_project_token()
107+
108+
109+
def _is_plain_object(value: Any) -> bool:
110+
return isinstance(value, dict)
111+
112+
113+
def _normalize_type(raw: str) -> str:
114+
s = (raw or "").strip()
115+
return s if s else UNKNOWN_TYPE
116+
117+
118+
def _normalize_location(location: Optional[str]) -> Optional[str]:
119+
if not isinstance(location, str):
120+
return None
121+
s = location.strip()
122+
return s or None
123+
124+
125+
def _maybe_data(data: Any) -> Optional[str]:
126+
if data is None:
127+
return None
128+
if isinstance(data, str):
129+
return data
130+
if _is_plain_object(data):
131+
try:
132+
return json.dumps(data)
133+
except (TypeError, ValueError):
134+
return None
135+
return None
136+
137+
138+
def _iso_timestamp_utc(dt: Optional[datetime] = None) -> str:
139+
now = dt or datetime.now(timezone.utc)
140+
if now.tzinfo is None:
141+
now = now.replace(tzinfo=timezone.utc)
142+
base = now.strftime("%Y-%m-%dT%H:%M:%S")
143+
micros = f"{now.microsecond:06d}"
144+
return f"{base}.{micros}Z"
145+
146+
147+
def _get_or_create_local_session() -> str:
148+
global _local_session_id
149+
if _local_session_id is None:
150+
_local_session_id = str(uuid.uuid4())
151+
return _local_session_id
152+
153+
154+
def _merged_runtime_for_send(project_token: str) -> Optional[Dict[str, Any]]:
155+
sess = ""
156+
styles = try_parse_resolved_styles()
157+
pid = ""
158+
need_fetch = not pid or not sess or styles is None
159+
160+
if need_fetch:
161+
raw = _fetch_proj_auth_cached(project_token)
162+
if raw is None:
163+
return None
164+
x = raw.get("project_id")
165+
if isinstance(x, str):
166+
pid = x.strip()
167+
elif x is not None:
168+
pid = str(x).strip()
169+
x = raw.get("session")
170+
sess = x.strip() if isinstance(x, str) else ""
171+
if styles is None:
172+
rows = raw.get("styles")
173+
rows = rows if isinstance(rows, list) else []
174+
styles = build_style_entries_from_api(rows)
175+
176+
if not pid or not sess:
177+
return None
178+
if styles is None:
179+
styles = build_style_entries_from_api([])
180+
return {"project_id": pid, "session": sess, "styles": styles}
181+
182+
183+
def _ensure_ws(project_token: str):
184+
global _ws, _bound_url
185+
url = build_create_browser_logs_url(resolve_ws_base_url(), project_token)
186+
if _ws is not None and _bound_url == url:
187+
return _ws
188+
_close_ws_connection()
189+
conn = create_connection(
190+
url,
191+
timeout=CONNECT_TIMEOUT_S,
192+
)
193+
_ws = conn
194+
_bound_url = url
195+
return conn
196+
197+
198+
def _styles_for_console(
199+
project_token: Optional[str], merged: Optional[Dict[str, Any]]
200+
) -> Any:
201+
s = try_parse_resolved_styles()
202+
if s is not None:
203+
return s
204+
if merged is not None:
205+
return merged.get("styles")
206+
if project_token:
207+
raw = _fetch_proj_auth_cached(project_token)
208+
if raw is not None:
209+
rows = raw.get("styles")
210+
rows = rows if isinstance(rows, list) else []
211+
return build_style_entries_from_api(rows)
212+
return None
213+
214+
215+
def client_log(
216+
type: str,
217+
message: str,
218+
location: Optional[str] = None,
219+
data: Any = None,
220+
) -> None:
221+
global _warned_missing_project_token, _warned_missing_project_id
222+
223+
project_token = _resolve_project_token_runtime()
224+
if not project_token:
225+
if not _warned_missing_project_token:
226+
_warned_missing_project_token = True
227+
print(
228+
"auralogger: missing project token. Call AuraClient.configure(project_token) "
229+
"or set AURALOGGER_PROJECT_TOKEN.",
230+
file=sys.stderr,
231+
)
232+
return
233+
234+
merged = _merged_runtime_for_send(project_token)
235+
can_send = merged is not None
236+
if not can_send and not _warned_missing_project_id:
237+
_warned_missing_project_id = True
238+
print(
239+
"auralogger: client logger is running in console-only mode. "
240+
"proj_auth did not yield a valid project id/session.",
241+
file=sys.stderr,
242+
)
243+
244+
styles = _styles_for_console(project_token, merged)
245+
display_session = (
246+
merged["session"] if (merged is not None and merged.get("session")) else _get_or_create_local_session()
247+
)
248+
249+
payload: Dict[str, Any] = {
250+
"type": _normalize_type(type),
251+
"message": "" if message is None else str(message),
252+
"session": display_session,
253+
"created_at": _iso_timestamp_utc(),
254+
}
255+
loc = _normalize_location(location)
256+
if loc is not None:
257+
payload["location"] = loc
258+
data_str = _maybe_data(data)
259+
if data_str is not None:
260+
payload["data"] = data_str
261+
262+
try:
263+
print_log(payload, styles)
264+
except Exception as e:
265+
print(f"auralogger: failed to print log: {e}", file=sys.stderr)
266+
267+
if not can_send:
268+
return
269+
270+
try:
271+
body = json.dumps(payload)
272+
except (TypeError, ValueError) as e:
273+
print(f"auralogger: failed to serialize log payload: {e}", file=sys.stderr)
274+
return
275+
276+
try:
277+
ws = _ensure_ws(project_token)
278+
ws.send(body)
279+
_schedule_socket_idle_close()
280+
except websocket.WebSocketTimeoutException as e:
281+
print(f"auralogger: websocket send failed (timeout): {e}", file=sys.stderr)
282+
close_client_log_socket()
283+
except Exception as e:
284+
print(f"auralogger: websocket send failed: {e}", file=sys.stderr)
285+
close_client_log_socket()
286+
287+
288+
def auralog(loginputs: ClientLogInputs) -> None:
289+
AuraClient.log(
290+
loginputs.type,
291+
loginputs.message,
292+
loginputs.location,
293+
loginputs.data,
294+
)
295+
296+
297+
class AuraClient:
298+
@staticmethod
299+
def configure(project_token: str) -> None:
300+
global _override_project_token, _warned_missing_project_token
301+
global _warned_missing_project_id, _local_session_id
302+
global _hydration_cache_token, _hydration_cache_raw
303+
trimmed = project_token.strip()
304+
if not trimmed:
305+
raise ValueError("AuraClient.configure: project token cannot be empty.")
306+
_override_project_token = trimmed
307+
_warned_missing_project_token = False
308+
_warned_missing_project_id = False
309+
_local_session_id = None
310+
with _hydrate_lock:
311+
_hydration_cache_token = None
312+
_hydration_cache_raw = None
313+
314+
@staticmethod
315+
def sync_from_secret(project_token: str) -> None:
316+
trimmed = project_token.strip()
317+
if not trimmed:
318+
raise ValueError("AuraClient.sync_from_secret: project token cannot be empty.")
319+
AuraClient.configure(trimmed)
320+
raw = fetch_proj_auth_payload(trimmed)
321+
project_id_raw = raw.get("project_id")
322+
session_raw = raw.get("session")
323+
project_id = project_id_raw.strip() if isinstance(project_id_raw, str) else ""
324+
session = session_raw.strip() if isinstance(session_raw, str) else ""
325+
if not project_id or not session:
326+
raise ValueError(
327+
"AuraClient.sync_from_secret: proj_auth response missing project id or session."
328+
)
329+
with _hydrate_lock:
330+
global _hydration_cache_token, _hydration_cache_raw
331+
_hydration_cache_token = trimmed
332+
_hydration_cache_raw = raw
333+
334+
@staticmethod
335+
def log(type: str, message: str, location: Optional[str] = None, data: Any = None) -> None:
336+
client_log(type, message, location, data)
337+
338+
@staticmethod
339+
def close_socket(timeout_ms: int = 1000) -> None:
340+
_ = timeout_ms
341+
close_client_log_socket()

0 commit comments

Comments
 (0)