1- """
2- Simple analytics for DeepFabric using PostHog.
3-
4- Provides a single trace() function for anonymous usage analytics.
5- All analytics can be disabled by setting ANONYMIZED_TELEMETRY=False.
6-
7- Privacy-respecting identity:
8- - Generates a stable, anonymous user ID based on machine characteristics
9- - Uses DEEPFABRIC_DEVELOPER=True to mark developer sessions for filtering
10- - Never collects PII (names, emails, IP addresses, etc.)
11- """
12-
131import contextlib
14- import hashlib
2+ import logging
153import os
16- import platform
174import uuid
185
6+ from pathlib import Path
7+
8+ from posthog import Posthog , identify_context , new_context
9+
10+ from .tui import get_tui
11+
1912try :
2013 import importlib .metadata
2114
2215 VERSION = importlib .metadata .version ("deepfabric" )
2316except (ImportError , importlib .metadata .PackageNotFoundError ):
2417 VERSION = "development"
2518
26- try :
27- from posthog import Posthog , identify_context , new_context
28-
29- POSTHOG_AVAILABLE = True
30- except ImportError :
31- POSTHOG_AVAILABLE = False
3219
3320# Initialize PostHog client
34- if POSTHOG_AVAILABLE :
35- posthog = Posthog (
36- project_api_key = "phc_Kn8hKQIXHm5OHp5OTxvMvFDUmT7HyOUNlJvWkduB9qO" ,
37- host = "https://us.i.posthog.com" ,
38- )
39- else :
40- posthog = None
21+ posthog = Posthog (
22+ project_api_key = "phc_Kn8hKQIXHm5OHp5OTxvMvFDUmT7HyOUNlJvWkduB9qO" ,
23+ host = "https://us.i.posthog.com" ,
24+ )
4125
42- # Cache for the generated user ID using a dict to avoid global statement
43- _user_id_cache : dict [str , str | None ] = {"id" : None }
26+ logger = logging .getLogger (__name__ )
4427
4528
46- def _get_user_id () -> str :
47- """
48- Generate a stable, anonymous user ID based on machine characteristics.
29+ class _TelemetryState :
30+ """Holds the state for the telemetry module."""
4931
50- Creates a UUID from a hash of platform.node() (hostname) and uuid.getnode()
51- (MAC address). This provides a persistent identifier across sessions without
52- collecting PII.
32+ def __init__ (self ) -> None :
33+ self .debug_trace : bool = False
34+ self .user_id_announced : bool = False
35+ self .user_id_cache : str | None = None
36+ self .telemetry_failed_once : bool = False
5337
54- Returns:
55- str: A stable UUID string unique to this machine
56- """
57- if _user_id_cache ["id" ] is not None :
58- return _user_id_cache ["id" ]
5938
60- # Combine hostname and MAC address for machine-specific identifier
61- machine_info = f" { platform . node () } - { uuid . getnode () } "
39+ _state = _TelemetryState ()
40+
6241
63- # Create SHA256 hash and convert to UUID format
64- hash_digest = hashlib . sha256 ( machine_info . encode ()). hexdigest ()
42+ APP_NAME = "DeepFabric"
43+ APP_AUTHOR = "DeepFabric"
6544
66- # Use first 32 hex chars to create a valid UUID
67- user_uuid = str (uuid .UUID (hash_digest [:32 ]))
6845
69- _user_id_cache ["id" ] = user_uuid
70- return user_uuid
46+ try :
47+ from platformdirs import user_data_dir
48+ except ImportError : # pragma: no cover - optional dependency
49+
50+ def user_data_dir (appname : str , appauthor : str | None = None ) -> str :
51+ if os .name == "nt" :
52+ base = os .environ .get ("APPDATA" ) or os .path .expanduser (r"~\AppData\Roaming" )
53+ elif os .name == "posix" :
54+ base = os .environ .get ("XDG_DATA_HOME" ) or os .path .expanduser ("~/.local/share" )
55+ else :
56+ base = os .path .expanduser ("~" )
57+ return str (Path (base ) / (appauthor or appname ) / appname )
58+
59+
60+ def _user_id_path () -> Path :
61+ candidates : list [Path ] = []
62+ try :
63+ candidates .append (Path (user_data_dir (APP_NAME , APP_AUTHOR )))
64+ except Exception :
65+ logger .debug ("Failed to resolve platform data dir" , exc_info = True )
66+ candidates .append (Path .home () / f".{ APP_NAME .lower ()} " )
67+ candidates .append (Path .cwd ())
68+
69+ for data_dir in candidates :
70+ try :
71+ data_dir .mkdir (parents = True , exist_ok = True )
72+ return data_dir / "telemetry_id"
73+ except Exception :
74+ logger .debug ("Failed to prepare telemetry directory %s" , data_dir , exc_info = True )
75+
76+ return Path ("telemetry_id" )
77+
78+
79+ def _read_user_id (path : Path ) -> str | None :
80+ try :
81+ if path .exists ():
82+ candidate = path .read_text (encoding = "utf-8" ).strip ()
83+ if candidate :
84+ uuid .UUID (candidate )
85+ return candidate
86+ except Exception :
87+ logger .debug ("Failed to read existing telemetry id" , exc_info = True )
88+ return None
89+
90+
91+ def _write_user_id (path : Path ) -> str :
92+ user_id = str (uuid .uuid4 ())
93+ tmp_path = path .with_suffix (".tmp" )
94+ try :
95+ tmp_path .write_text (user_id , encoding = "utf-8" )
96+ if os .name == "posix" :
97+ os .chmod (tmp_path , 0o600 )
98+ tmp_path .replace (path )
99+ except Exception :
100+ logger .debug ("Failed to persist telemetry id" , exc_info = True )
101+ return user_id
102+ else :
103+ return user_id
104+ finally :
105+ with contextlib .suppress (Exception ):
106+ if tmp_path .exists () and tmp_path != path :
107+ tmp_path .unlink ()
108+
109+
110+ def _get_user_id () -> str :
111+ """Generate a stable, anonymous user ID persisted on disk."""
112+ if _state .user_id_cache is not None :
113+ return _state .user_id_cache
114+
115+ path = _user_id_path ()
116+ user_id = _read_user_id (path )
117+ if user_id is None :
118+ user_id = _write_user_id (path )
119+
120+ _state .user_id_cache = user_id
121+ return user_id
71122
72123
73124def _is_developer () -> bool :
@@ -80,33 +131,43 @@ def _is_developer() -> bool:
80131 return os .environ .get ("DEEPFABRIC_DEVELOPER" ) == "True"
81132
82133
134+ def set_trace_debug (enabled : bool ) -> None :
135+ """Enable or disable debug output for telemetry events."""
136+ _state .debug_trace = enabled
137+ if not enabled :
138+ _state .user_id_announced = False
139+
140+
141+ def _announce_user_id (user_id : str ) -> None :
142+ if _state .user_id_announced or not _state .debug_trace :
143+ return
144+ try :
145+ get_tui ().info (f"metrics user id: { user_id } " )
146+ except Exception : # pragma: no cover - fallback to logging
147+ logger .debug ("metrics user id: %s" , user_id )
148+
149+ _state .user_id_announced = True
150+
151+
83152def trace (event_name , event_properties = None ):
84153 """
85- Send an analytics event if telemetry is enabled.
154+ Send an analytics event if metrics is enabled.
86155
87156 Uses privacy-respecting identity tracking with a stable, anonymous user ID
88- generated from machine characteristics . Developer sessions are marked with
157+ stored on disk for reuse . Developer sessions are marked with
89158 the is_developer flag when DEEPFABRIC_DEVELOPER=True.
90159
91160 Args:
92161 event_name: Name of the event to track
93162 event_properties: Optional dictionary of event properties
94163 """
95- # Quick exit if telemetry is disabled
96- if os .environ .get ("ANONYMIZED_TELEMETRY" ) == "False" :
164+ if not is_enabled ():
97165 return
98166
99- # Quick exit if during testing
100- if os .environ .get ("DEEPFABRIC_TESTING" ) == "True" :
101- return
102-
103- # Quick exit if PostHog not available
104- if not POSTHOG_AVAILABLE or not posthog :
105- return
106-
107- with contextlib .suppress (Exception ):
167+ try :
108168 # Generate stable user ID
109169 user_id = _get_user_id ()
170+ _announce_user_id (user_id )
110171
111172 # Add version and developer flag to all events
112173 properties = event_properties or {}
@@ -116,13 +177,36 @@ def trace(event_name, event_properties=None):
116177 # Use identity context to associate events with the user
117178 with new_context ():
118179 identify_context (user_id )
119- posthog .capture (event = event_name , properties = properties )
180+ posthog .capture (
181+ distinct_id = user_id ,
182+ event = event_name ,
183+ properties = properties ,
184+ )
185+ except Exception :
186+ if not _state .telemetry_failed_once :
187+ _state .telemetry_failed_once = True
188+ logger .warning (
189+ "Failed to send telemetry event. Further failures will be logged at DEBUG level." ,
190+ exc_info = True ,
191+ )
192+ else :
193+ logger .debug ("Failed to capture metrics event" , exc_info = True )
120194
121195
122196def is_enabled ():
123197 """Check if analytics is currently enabled."""
124198 return (
125199 os .environ .get ("ANONYMIZED_TELEMETRY" ) != "False"
126200 and os .environ .get ("DEEPFABRIC_TESTING" ) != "True"
127- and POSTHOG_AVAILABLE
128201 )
202+
203+
204+ def shutdown () -> None :
205+ """
206+ Shutdown the PostHog client, flushing any buffered events.
207+
208+ This should be called on application exit to ensure all metrics data is sent.
209+ """
210+ if is_enabled ():
211+ logger .debug ("Shutting down metrics client." )
212+ posthog .shutdown ()
0 commit comments