vnai 2.1.7__py3-none-any.whl → 2.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vnai/flow/relay.py CHANGED
@@ -1,356 +1,447 @@
1
- import time
2
- import threading
3
- import json
4
- import random
5
- import requests
6
- from datetime import datetime
7
- from pathlib import Path
8
- from typing import Dict, List, Any, Optional
9
-
10
- class Conduit:
11
- _instance = None
12
- _lock = threading.Lock()
13
-
14
- def __new__(cls, webhook_url=None, buffer_size=50, sync_interval=300):
15
- with cls._lock:
16
- if cls._instance is None:
17
- cls._instance = super(Conduit, cls).__new__(cls)
18
- cls._instance._initialize(webhook_url, buffer_size, sync_interval)
19
- return cls._instance
20
-
21
- def _initialize(self, webhook_url, buffer_size, sync_interval):
22
- self.webhook_url = webhook_url
23
- self.buffer_size = buffer_size
24
- self.sync_interval = sync_interval
25
- self.buffer = {
26
- "function_calls": [],
27
- "api_requests": [],
28
- "rate_limits": []
29
- }
30
- self.lock = threading.Lock()
31
- self.last_sync_time = time.time()
32
- self.sync_count = 0
33
- self.failed_queue = []
34
- self.home_dir = Path.home()
35
- self.project_dir = self.home_dir /".vnstock"
36
- self.project_dir.mkdir(exist_ok=True)
37
- self.data_dir = self.project_dir /'data'
38
- self.data_dir.mkdir(exist_ok=True)
39
- self.config_path = self.data_dir /"relay_config.json"
40
- try:
41
- from vnai.scope.profile import inspector
42
- self.machine_id = inspector.fingerprint()
43
- except:
44
- self.machine_id = self._generate_fallback_id()
45
- self._load_config()
46
- self._start_periodic_sync()
47
-
48
- def _generate_fallback_id(self) -> str:
49
- try:
50
- import platform
51
- import hashlib
52
- import uuid
53
- system_info = platform.node() + platform.platform() + platform.processor()
54
- return hashlib.md5(system_info.encode()).hexdigest()
55
- except:
56
- import uuid
57
- return str(uuid.uuid4())
58
-
59
- def _load_config(self):
60
- if self.config_path.exists():
61
- try:
62
- with open(self.config_path,'r') as f:
63
- config = json.load(f)
64
- if not self.webhook_url and'webhook_url' in config:
65
- self.webhook_url = config['webhook_url']
66
- if'buffer_size' in config:
67
- self.buffer_size = config['buffer_size']
68
- if'sync_interval' in config:
69
- self.sync_interval = config['sync_interval']
70
- if'last_sync_time' in config:
71
- self.last_sync_time = config['last_sync_time']
72
- if'sync_count' in config:
73
- self.sync_count = config['sync_count']
74
- except:
75
- pass
76
-
77
- def _save_config(self):
78
- config = {
79
- 'webhook_url': self.webhook_url,
80
- 'buffer_size': self.buffer_size,
81
- 'sync_interval': self.sync_interval,
82
- 'last_sync_time': self.last_sync_time,
83
- 'sync_count': self.sync_count
84
- }
85
- try:
86
- with open(self.config_path,'w') as f:
87
- json.dump(config, f)
88
- except:
89
- pass
90
-
91
- def _start_periodic_sync(self):
92
- def periodic_sync():
93
- while True:
94
- time.sleep(self.sync_interval)
95
- self.dispatch("periodic")
96
- sync_thread = threading.Thread(target=periodic_sync, daemon=True)
97
- sync_thread.start()
98
-
99
- def add_function_call(self, record):
100
- if not isinstance(record, dict):
101
- record = {"value": str(record)}
102
- with self.lock:
103
- self.buffer["function_calls"].append(record)
104
- self._check_triggers("function_calls")
105
-
106
- def add_api_request(self, record):
107
- if not isinstance(record, dict):
108
- record = {"value": str(record)}
109
- with self.lock:
110
- self.buffer["api_requests"].append(record)
111
- self._check_triggers("api_requests")
112
-
113
- def add_rate_limit(self, record):
114
- if not isinstance(record, dict):
115
- record = {"value": str(record)}
116
- with self.lock:
117
- self.buffer["rate_limits"].append(record)
118
- self._check_triggers("rate_limits")
119
-
120
- def _check_triggers(self, record_type: str):
121
- current_time = time.time()
122
- should_trigger = False
123
- trigger_reason = None
124
- total_records = sum(len(buffer) for buffer in self.buffer.values())
125
- if total_records >= self.buffer_size:
126
- should_trigger = True
127
- trigger_reason ="buffer_full"
128
- elif record_type =="rate_limits" and self.buffer["rate_limits"] and any(item.get("is_exceeded") for item in self.buffer["rate_limits"] if isinstance(item, dict)):
129
- should_trigger = True
130
- trigger_reason ="rate_limit_exceeded"
131
- elif record_type =="function_calls" and self.buffer["function_calls"] and any(not item.get("success") for item in self.buffer["function_calls"] if isinstance(item, dict)):
132
- should_trigger = True
133
- trigger_reason ="function_error"
134
- else:
135
- time_factor = min(1.0, (current_time - self.last_sync_time) / (self.sync_interval / 2))
136
- if random.random() < 0.05 * time_factor:
137
- should_trigger = True
138
- trigger_reason ="random_time_weighted"
139
- if should_trigger:
140
- threading.Thread(
141
- target=self.dispatch,
142
- args=(trigger_reason,),
143
- daemon=True
144
- ).start()
145
-
146
- def queue(self, package, priority=None):
147
- try:
148
- from vnai.scope.promo import ContentManager
149
- is_paid = ContentManager().is_paid_user
150
- segment_val ="paid" if is_paid else"free"
151
- except Exception:
152
- segment_val ="free"
153
-
154
- def ensure_segment(d):
155
- if not isinstance(d, dict):
156
- return d
157
- d = dict(d)
158
- if"segment" not in d:
159
- d["segment"] = segment_val
160
- return d
161
- if isinstance(package, dict) and"segment" not in package:
162
- import base64
163
- api_key = base64.b64decode("MXlJOEtnYXJudFFyMHB0cmlzZUhoYjRrZG9ta2VueU5JOFZQaXlrNWFvVQ==").decode()
164
- package["segment"] = segment_val
165
- if isinstance(package, dict) and isinstance(package.get("data"), dict):
166
- if"segment" not in package["data"]:
167
- package["data"]["segment"] = segment_val
168
- """Queue data package"""
169
- if not package:
170
- return False
171
- if not isinstance(package, dict):
172
- self.add_function_call(ensure_segment({"message": str(package)}))
173
- return True
174
- if"timestamp" not in package:
175
- package["timestamp"] = datetime.now().isoformat()
176
- if"type" in package:
177
- package_type = package["type"]
178
- data = package.get("data", {})
179
- if isinstance(data, dict) and"system" in data:
180
- machine_id = data["system"].get("machine_id")
181
- data.pop("system")
182
- if machine_id:
183
- data["machine_id"] = machine_id
184
- if package_type =="function":
185
- self.add_function_call(ensure_segment(data))
186
- elif package_type =="api_request":
187
- self.add_api_request(ensure_segment(data))
188
- elif package_type =="rate_limit":
189
- self.add_rate_limit(ensure_segment(data))
190
- elif package_type =="system_info":
191
- self.add_function_call({
192
- "type":"system_info",
193
- "commercial": data.get("commercial"),
194
- "packages": data.get("packages"),
195
- "timestamp": package.get("timestamp")
196
- })
197
- elif package_type =="metrics":
198
- metrics_data = data
199
- for metric_type, metrics_list in metrics_data.items():
200
- if isinstance(metrics_list, list):
201
- if metric_type =="function":
202
- for item in metrics_list:
203
- self.add_function_call(ensure_segment(item))
204
- elif metric_type =="rate_limit":
205
- for item in metrics_list:
206
- self.add_rate_limit(ensure_segment(item))
207
- elif metric_type =="request":
208
- for item in metrics_list:
209
- self.add_api_request(ensure_segment(item))
210
- else:
211
- if isinstance(data, dict) and data is not package:
212
- self.add_function_call(ensure_segment(data))
213
- else:
214
- self.add_function_call(ensure_segment(package))
215
- else:
216
- self.add_function_call(ensure_segment(package))
217
- if priority =="high":
218
- self.dispatch("high_priority")
219
- return True
220
-
221
- def _send_data(self, payload):
222
- import base64
223
- api_key = base64.b64decode("MXlJOEtnYXJudFFyMHB0cmlzZUhoYjRrZG9ta2VueU5JOFZQaXlrNWFvVQ==").decode()
224
- url ="https://hq.vnstocks.com/analytics"
225
- headers = {
226
- "x-api-key": api_key,
227
- "Content-Type":"application/json"
228
- }
229
- try:
230
- response = requests.post(url, json=payload, headers=headers, timeout=5)
231
- return response.status_code == 200
232
- except Exception:
233
- return False
234
-
235
- def dispatch(self, reason="manual"):
236
- if not self.webhook_url:
237
- return False
238
- with self.lock:
239
- if all(len(records) == 0 for records in self.buffer.values()):
240
- return False
241
- data_to_send = {
242
- "function_calls": self.buffer["function_calls"].copy(),
243
- "api_requests": self.buffer["api_requests"].copy(),
244
- "rate_limits": self.buffer["rate_limits"].copy()
245
- }
246
- self.buffer = {
247
- "function_calls": [],
248
- "api_requests": [],
249
- "rate_limits": []
250
- }
251
- self.last_sync_time = time.time()
252
- self.sync_count += 1
253
- self._save_config()
254
- try:
255
- from vnai.scope.profile import inspector
256
- environment_info = inspector.examine()
257
- machine_id = environment_info.get("machine_id", self.machine_id)
258
- except:
259
- environment_info = {"machine_id": self.machine_id}
260
- machine_id = self.machine_id
261
- payload = {
262
- "analytics_data": data_to_send,
263
- "metadata": {
264
- "timestamp": datetime.now().isoformat(),
265
- "machine_id": machine_id,
266
- "sync_count": self.sync_count,
267
- "trigger_reason": reason,
268
- "environment": environment_info,
269
- "data_counts": {
270
- "function_calls": len(data_to_send["function_calls"]),
271
- "api_requests": len(data_to_send["api_requests"]),
272
- "rate_limits": len(data_to_send["rate_limits"])
273
- }
274
- }
275
- }
276
- success = self._send_data(payload)
277
- if not success:
278
- with self.lock:
279
- self.failed_queue.append(payload)
280
- if len(self.failed_queue) > 10:
281
- self.failed_queue = self.failed_queue[-10:]
282
- with self.lock:
283
- to_retry = self.failed_queue.copy()
284
- self.failed_queue = []
285
- success_count = 0
286
- for payload in to_retry:
287
- if self._send_data(payload):
288
- success_count += 1
289
- else:
290
- with self.lock:
291
- self.failed_queue.append(payload)
292
- return success_count
293
-
294
- def configure(self, webhook_url):
295
- with self.lock:
296
- self.webhook_url = webhook_url
297
- self._save_config()
298
- return True
299
- conduit = Conduit()
300
-
301
- def track_function_call(function_name, source, execution_time, success=True, error=None, args=None):
302
- record = {
303
- "function": function_name,
304
- "source": source,
305
- "execution_time": execution_time,
306
- "timestamp": datetime.now().isoformat(),
307
- "success": success
308
- }
309
- if error:
310
- record["error"] = error
311
- if args:
312
- sanitized_args = {}
313
- if isinstance(args, dict):
314
- for key, value in args.items():
315
- if isinstance(value, (str, int, float, bool)):
316
- sanitized_args[key] = value
317
- else:
318
- sanitized_args[key] = str(type(value))
319
- else:
320
- sanitized_args = {"value": str(args)}
321
- record["args"] = sanitized_args
322
- conduit.add_function_call(record)
323
-
324
- def track_rate_limit(source, limit_type, limit_value, current_usage, is_exceeded):
325
- record = {
326
- "source": source,
327
- "limit_type": limit_type,
328
- "limit_value": limit_value,
329
- "current_usage": current_usage,
330
- "is_exceeded": is_exceeded,
331
- "timestamp": datetime.now().isoformat(),
332
- "usage_percentage": (current_usage / limit_value) * 100 if limit_value > 0 else 0
333
- }
334
- conduit.add_rate_limit(record)
335
-
336
- def track_api_request(endpoint, source, method, status_code, execution_time, request_size=0, response_size=0):
337
- record = {
338
- "endpoint": endpoint,
339
- "source": source,
340
- "method": method,
341
- "status_code": status_code,
342
- "execution_time": execution_time,
343
- "timestamp": datetime.now().isoformat(),
344
- "request_size": request_size,
345
- "response_size": response_size
346
- }
347
- conduit.add_api_request(record)
348
-
349
- def configure(webhook_url):
350
- return conduit.configure(webhook_url)
351
-
352
- def sync_now():
353
- return conduit.dispatch("manual")
354
-
355
- def retry_failed():
356
- return conduit.retry_failed()
1
+ # vnai/flow/relay.py
2
+
3
+ import time
4
+ import threading
5
+ import json
6
+ import random
7
+ import requests
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Dict, List, Any, Optional
11
+
12
+ class Conduit:
13
+ """Handles system telemetry flow"""
14
+
15
+ _instance = None
16
+ _lock = threading.Lock()
17
+
18
+ def __new__(cls, buffer_size=50, sync_interval=300):
19
+ with cls._lock:
20
+ if cls._instance is None:
21
+ cls._instance = super(Conduit, cls).__new__(cls)
22
+ cls._instance._initialize(buffer_size, sync_interval)
23
+ return cls._instance
24
+
25
+ def _initialize(self, buffer_size, sync_interval):
26
+ """Initialize conduit"""
27
+ self.buffer_size = buffer_size
28
+ self.sync_interval = sync_interval
29
+
30
+ # Separate buffers for different data types
31
+ self.buffer = {
32
+ "function_calls": [],
33
+ "api_requests": [],
34
+ "rate_limits": []
35
+ }
36
+
37
+ self.lock = threading.Lock()
38
+ self.last_sync_time = time.time()
39
+ self.sync_count = 0
40
+ self.failed_queue = []
41
+
42
+ # Home directory setup
43
+ self.home_dir = Path.home()
44
+ self.project_dir = self.home_dir / ".vnstock"
45
+ self.project_dir.mkdir(exist_ok=True)
46
+ self.data_dir = self.project_dir / 'data'
47
+ self.data_dir.mkdir(exist_ok=True)
48
+ self.config_path = self.data_dir / "relay_config.json"
49
+
50
+ # Get machine identifier from system profile
51
+ try:
52
+ from vnai.scope.profile import inspector
53
+ self.machine_id = inspector.fingerprint()
54
+ except:
55
+ self.machine_id = self._generate_fallback_id()
56
+
57
+ # Load config if exists
58
+ self._load_config()
59
+
60
+ # Start periodic sync
61
+ self._start_periodic_sync()
62
+
63
+ def _generate_fallback_id(self) -> str:
64
+ """Generate a fallback machine identifier if profile is unavailable"""
65
+ try:
66
+ import platform
67
+ import hashlib
68
+ import uuid
69
+
70
+ # Try to get machine-specific information
71
+ system_info = platform.node() + platform.platform() + platform.processor()
72
+ return hashlib.md5(system_info.encode()).hexdigest()
73
+ except:
74
+ import uuid
75
+ return str(uuid.uuid4())
76
+
77
+ def _load_config(self):
78
+ """Load configuration from file"""
79
+ if self.config_path.exists():
80
+ try:
81
+ with open(self.config_path, 'r') as f:
82
+ config = json.load(f)
83
+
84
+
85
+ if 'buffer_size' in config:
86
+ self.buffer_size = config['buffer_size']
87
+ if 'sync_interval' in config:
88
+ self.sync_interval = config['sync_interval']
89
+ if 'last_sync_time' in config:
90
+ self.last_sync_time = config['last_sync_time']
91
+ if 'sync_count' in config:
92
+ self.sync_count = config['sync_count']
93
+ except:
94
+ pass
95
+
96
+ def _save_config(self):
97
+ """Save configuration to file"""
98
+ config = {
99
+
100
+ 'buffer_size': self.buffer_size,
101
+ 'sync_interval': self.sync_interval,
102
+ 'last_sync_time': self.last_sync_time,
103
+ 'sync_count': self.sync_count
104
+ }
105
+
106
+ try:
107
+ with open(self.config_path, 'w') as f:
108
+ json.dump(config, f)
109
+ except:
110
+ pass
111
+
112
+ def _start_periodic_sync(self):
113
+ """Start periodic sync thread"""
114
+ def periodic_sync():
115
+ while True:
116
+ time.sleep(self.sync_interval)
117
+ self.dispatch("periodic")
118
+
119
+ sync_thread = threading.Thread(target=periodic_sync, daemon=True)
120
+ sync_thread.start()
121
+
122
+ def add_function_call(self, record):
123
+ """Add function call record"""
124
+ # Ensure record is a dictionary
125
+ if not isinstance(record, dict):
126
+ record = {"value": str(record)}
127
+
128
+ with self.lock:
129
+ self.buffer["function_calls"].append(record)
130
+ self._check_triggers("function_calls")
131
+
132
+ def add_api_request(self, record):
133
+ """Add API request record"""
134
+ # Ensure record is a dictionary
135
+ if not isinstance(record, dict):
136
+ record = {"value": str(record)}
137
+
138
+ with self.lock:
139
+ self.buffer["api_requests"].append(record)
140
+ self._check_triggers("api_requests")
141
+
142
+ def add_rate_limit(self, record):
143
+ """Add rate limit record"""
144
+ # Ensure record is a dictionary
145
+ if not isinstance(record, dict):
146
+ record = {"value": str(record)}
147
+
148
+ with self.lock:
149
+ self.buffer["rate_limits"].append(record)
150
+ self._check_triggers("rate_limits")
151
+
152
+ def _check_triggers(self, record_type: str):
153
+ """Check if any sync triggers are met"""
154
+ current_time = time.time()
155
+ should_trigger = False
156
+ trigger_reason = None
157
+
158
+ # Get total buffer size
159
+ total_records = sum(len(buffer) for buffer in self.buffer.values())
160
+
161
+ # SIZE TRIGGER: Buffer size threshold reached
162
+ if total_records >= self.buffer_size:
163
+ should_trigger = True
164
+ trigger_reason = "buffer_full"
165
+
166
+ # EVENT TRIGGER: Critical events (errors, rate limit warnings)
167
+ elif record_type == "rate_limits" and self.buffer["rate_limits"] and \
168
+ any(item.get("is_exceeded") for item in self.buffer["rate_limits"] if isinstance(item, dict)):
169
+ should_trigger = True
170
+ trigger_reason = "rate_limit_exceeded"
171
+ elif record_type == "function_calls" and self.buffer["function_calls"] and \
172
+ any(not item.get("success") for item in self.buffer["function_calls"] if isinstance(item, dict)):
173
+ should_trigger = True
174
+ trigger_reason = "function_error"
175
+
176
+ # TIME-WEIGHTED RANDOM TRIGGER: More likely as time since last sync increases
177
+ else:
178
+ time_factor = min(1.0, (current_time - self.last_sync_time) / (self.sync_interval / 2))
179
+ if random.random() < 0.05 * time_factor: # 0-5% chance based on time
180
+ should_trigger = True
181
+ trigger_reason = "random_time_weighted"
182
+
183
+ if should_trigger:
184
+ threading.Thread(
185
+ target=self.dispatch,
186
+ args=(trigger_reason,),
187
+ daemon=True
188
+ ).start()
189
+
190
+ def queue(self, package, priority=None):
191
+ # --- Auto add 'segment' field to every payload ---
192
+ try:
193
+ from vnai.scope.promo import ContentManager
194
+ is_paid = ContentManager().is_paid_user
195
+ segment_val = "paid" if is_paid else "free"
196
+ except Exception:
197
+ segment_val = "free"
198
+
199
+ def ensure_segment(d):
200
+ if not isinstance(d, dict):
201
+ return d
202
+ d = dict(d) # tạo bản sao để không ảnh hưởng dict gốc
203
+ if "segment" not in d:
204
+ d["segment"] = segment_val
205
+ return d
206
+ # Add segment to package if not present
207
+ if isinstance(package, dict) and "segment" not in package:
208
+ # API key is base64-encoded for obfuscation
209
+ import base64
210
+ api_key = base64.b64decode("MXlJOEtnYXJudFFyMHB0cmlzZUhoYjRrZG9ta2VueU5JOFZQaXlrNWFvVQ==").decode()
211
+ package["segment"] = segment_val
212
+ # Add segment to data if exists and is dict
213
+ if isinstance(package, dict) and isinstance(package.get("data"), dict):
214
+ if "segment" not in package["data"]:
215
+ package["data"]["segment"] = segment_val
216
+ # --- End auto segment ---
217
+
218
+ """Queue data package"""
219
+ if not package:
220
+ return False
221
+
222
+ # Handle non-dictionary packages
223
+ if not isinstance(package, dict):
224
+ self.add_function_call(ensure_segment({"message": str(package)}))
225
+ return True
226
+
227
+ # Add timestamp if not present
228
+ if "timestamp" not in package:
229
+ package["timestamp"] = datetime.now().isoformat()
230
+
231
+ # Route based on package type
232
+ if "type" in package:
233
+ package_type = package["type"]
234
+ data = package.get("data", {})
235
+
236
+ # Remove system info if present to avoid duplication
237
+ if isinstance(data, dict) and "system" in data:
238
+ # Get machine_id for reference but don't duplicate the whole system info
239
+ machine_id = data["system"].get("machine_id")
240
+ data.pop("system")
241
+ if machine_id:
242
+ data["machine_id"] = machine_id
243
+ if package_type == "function":
244
+ self.add_function_call(ensure_segment(data))
245
+ elif package_type == "api_request":
246
+ self.add_api_request(ensure_segment(data))
247
+ elif package_type == "rate_limit":
248
+ self.add_rate_limit(ensure_segment(data))
249
+ elif package_type == "system_info":
250
+ # For system info, we'll add it as a special function call
251
+ # but remove duplicated data
252
+ self.add_function_call({
253
+ "type": "system_info",
254
+ "commercial": data.get("commercial"),
255
+ "packages": data.get("packages"),
256
+ "timestamp": package.get("timestamp")
257
+ })
258
+ elif package_type == "metrics":
259
+ # Handle metrics package with multiple categories
260
+ metrics_data = data
261
+ for metric_type, metrics_list in metrics_data.items():
262
+ if isinstance(metrics_list, list):
263
+ if metric_type == "function":
264
+ for item in metrics_list:
265
+ self.add_function_call(ensure_segment(item))
266
+ elif metric_type == "rate_limit":
267
+ for item in metrics_list:
268
+ self.add_rate_limit(ensure_segment(item))
269
+ elif metric_type == "request":
270
+ for item in metrics_list:
271
+ self.add_api_request(ensure_segment(item))
272
+ else:
273
+ # Default to function calls
274
+ if isinstance(data, dict) and data is not package:
275
+ self.add_function_call(ensure_segment(data))
276
+ else:
277
+ self.add_function_call(ensure_segment(package))
278
+ else:
279
+ # No type specified, default to function call
280
+ self.add_function_call(ensure_segment(package))
281
+
282
+ # Handle high priority items
283
+ if priority == "high":
284
+ self.dispatch("high_priority")
285
+
286
+ return True
287
+
288
+ def _send_data(self, payload):
289
+ """Send analytics data to the configured endpoint with required headers."""
290
+ import base64
291
+ api_key = base64.b64decode("MXlJOEtnYXJudFFyMHB0cmlzZUhoYjRrZG9ta2VueU5JOFZQaXlrNWFvVQ==").decode()
292
+ url = "https://hq.vnstocks.com/analytics"
293
+ headers = {
294
+ "x-api-key": api_key,
295
+ "Content-Type": "application/json"
296
+ }
297
+ try:
298
+ response = requests.post(url, json=payload, headers=headers, timeout=5)
299
+ return response.status_code == 200
300
+ except Exception:
301
+ return False
302
+
303
+ def dispatch(self, reason="manual"):
304
+ """Send queued data"""
305
+ # (webhook_url logic removed, always proceed)
306
+ with self.lock:
307
+ # Check if all buffers are empty
308
+ if all(len(records) == 0 for records in self.buffer.values()):
309
+ return False
310
+
311
+ # Create a copy of the buffer for sending
312
+ data_to_send = {
313
+ "function_calls": self.buffer["function_calls"].copy(),
314
+ "api_requests": self.buffer["api_requests"].copy(),
315
+ "rate_limits": self.buffer["rate_limits"].copy()
316
+ }
317
+
318
+ # Clear buffer
319
+ self.buffer = {
320
+ "function_calls": [],
321
+ "api_requests": [],
322
+ "rate_limits": []
323
+ }
324
+
325
+ # Update sync time and count
326
+ self.last_sync_time = time.time()
327
+ self.sync_count += 1
328
+ self._save_config()
329
+
330
+ # Get environment information ONCE
331
+ try:
332
+ from vnai.scope.profile import inspector
333
+ environment_info = inspector.examine()
334
+ machine_id = environment_info.get("machine_id", self.machine_id)
335
+ except:
336
+ # Fallback if environment info isn't available
337
+ environment_info = {"machine_id": self.machine_id}
338
+ machine_id = self.machine_id
339
+
340
+ # Create payload with environment info only in metadata
341
+ payload = {
342
+ "analytics_data": data_to_send,
343
+ "metadata": {
344
+ "timestamp": datetime.now().isoformat(),
345
+ "machine_id": machine_id,
346
+ "sync_count": self.sync_count,
347
+ "trigger_reason": reason,
348
+ "environment": environment_info,
349
+ "data_counts": {
350
+ "function_calls": len(data_to_send["function_calls"]),
351
+ "api_requests": len(data_to_send["api_requests"]),
352
+ "rate_limits": len(data_to_send["rate_limits"])
353
+ }
354
+ }
355
+ }
356
+
357
+ # Send data
358
+ success = self._send_data(payload)
359
+
360
+ if not success:
361
+ with self.lock:
362
+ self.failed_queue.append(payload)
363
+ if len(self.failed_queue) > 10:
364
+ self.failed_queue = self.failed_queue[-10:]
365
+ with self.lock:
366
+ to_retry = self.failed_queue.copy()
367
+ self.failed_queue = []
368
+
369
+ success_count = 0
370
+ for payload in to_retry:
371
+ if self._send_data(payload):
372
+ success_count += 1
373
+ else:
374
+ with self.lock:
375
+ self.failed_queue.append(payload)
376
+
377
+ return success_count
378
+
379
+ # Create singleton instance
380
+ conduit = Conduit()
381
+
382
+ # Exposed functions that match sync.py naming pattern
383
+ def track_function_call(function_name, source, execution_time, success=True, error=None, args=None):
384
+ """Track function call (bridge to add_function_call)"""
385
+ record = {
386
+ "function": function_name,
387
+ "source": source,
388
+ "execution_time": execution_time,
389
+ "timestamp": datetime.now().isoformat(),
390
+ "success": success
391
+ }
392
+
393
+ if error:
394
+ record["error"] = error
395
+
396
+ if args:
397
+ # Sanitize arguments
398
+ sanitized_args = {}
399
+ if isinstance(args, dict):
400
+ for key, value in args.items():
401
+ if isinstance(value, (str, int, float, bool)):
402
+ sanitized_args[key] = value
403
+ else:
404
+ sanitized_args[key] = str(type(value))
405
+ else:
406
+ sanitized_args = {"value": str(args)}
407
+ record["args"] = sanitized_args
408
+
409
+ conduit.add_function_call(record)
410
+
411
+ def track_rate_limit(source, limit_type, limit_value, current_usage, is_exceeded):
412
+ """Track rate limit checks (bridge to add_rate_limit)"""
413
+ record = {
414
+ "source": source,
415
+ "limit_type": limit_type,
416
+ "limit_value": limit_value,
417
+ "current_usage": current_usage,
418
+ "is_exceeded": is_exceeded,
419
+ "timestamp": datetime.now().isoformat(),
420
+ "usage_percentage": (current_usage / limit_value) * 100 if limit_value > 0 else 0
421
+ }
422
+
423
+ conduit.add_rate_limit(record)
424
+
425
+ def track_api_request(endpoint, source, method, status_code, execution_time, request_size=0, response_size=0):
426
+ """Track API requests (bridge to add_api_request)"""
427
+ record = {
428
+ "endpoint": endpoint,
429
+ "source": source,
430
+ "method": method,
431
+ "status_code": status_code,
432
+ "execution_time": execution_time,
433
+ "timestamp": datetime.now().isoformat(),
434
+ "request_size": request_size,
435
+ "response_size": response_size
436
+ }
437
+
438
+ conduit.add_api_request(record)
439
+
440
+
441
+ def sync_now():
442
+ """Manually trigger synchronization"""
443
+ return conduit.dispatch("manual")
444
+
445
+ def retry_failed():
446
+ """Retry failed synchronizations"""
447
+ return conduit.retry_failed()