matrice-analytics 0.1.60__py3-none-any.whl → 0.1.89__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.
Files changed (21) hide show
  1. matrice_analytics/post_processing/config.py +2 -2
  2. matrice_analytics/post_processing/core/base.py +1 -1
  3. matrice_analytics/post_processing/face_reg/embedding_manager.py +8 -8
  4. matrice_analytics/post_processing/face_reg/face_recognition.py +886 -201
  5. matrice_analytics/post_processing/face_reg/face_recognition_client.py +68 -2
  6. matrice_analytics/post_processing/usecases/advanced_customer_service.py +908 -498
  7. matrice_analytics/post_processing/usecases/color_detection.py +18 -18
  8. matrice_analytics/post_processing/usecases/customer_service.py +356 -9
  9. matrice_analytics/post_processing/usecases/fire_detection.py +149 -11
  10. matrice_analytics/post_processing/usecases/license_plate_monitoring.py +548 -40
  11. matrice_analytics/post_processing/usecases/people_counting.py +11 -11
  12. matrice_analytics/post_processing/usecases/vehicle_monitoring.py +34 -34
  13. matrice_analytics/post_processing/usecases/weapon_detection.py +98 -22
  14. matrice_analytics/post_processing/utils/alert_instance_utils.py +950 -0
  15. matrice_analytics/post_processing/utils/business_metrics_manager_utils.py +1245 -0
  16. matrice_analytics/post_processing/utils/incident_manager_utils.py +1657 -0
  17. {matrice_analytics-0.1.60.dist-info → matrice_analytics-0.1.89.dist-info}/METADATA +1 -1
  18. {matrice_analytics-0.1.60.dist-info → matrice_analytics-0.1.89.dist-info}/RECORD +21 -18
  19. {matrice_analytics-0.1.60.dist-info → matrice_analytics-0.1.89.dist-info}/WHEEL +0 -0
  20. {matrice_analytics-0.1.60.dist-info → matrice_analytics-0.1.89.dist-info}/licenses/LICENSE.txt +0 -0
  21. {matrice_analytics-0.1.60.dist-info → matrice_analytics-0.1.89.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1245 @@
1
+ """
2
+ business_metrics_manager_utils.py
3
+
4
+ Manages business metrics aggregation and publishing to Redis/Kafka.
5
+ Aggregates metrics for 5 minutes (300 seconds) and pushes to output topic.
6
+ Supports aggregation types: mean (default), min, max, sum.
7
+
8
+ PRODUCTION-READY VERSION
9
+ """
10
+
11
+ import json
12
+ import time
13
+ import threading
14
+ import logging
15
+ import os
16
+ import urllib.request
17
+ import base64
18
+ import re
19
+ from typing import Dict, List, Optional, Any, Union
20
+ from datetime import datetime, timezone
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+
25
+ # Default aggregation interval in seconds (5 minutes)
26
+ DEFAULT_AGGREGATION_INTERVAL = 300
27
+
28
+ # Supported aggregation types
29
+ AGGREGATION_TYPES = ["mean", "min", "max", "sum"]
30
+
31
+ # Default metrics configuration with aggregation type
32
+ DEFAULT_METRICS_CONFIG = {
33
+ "customer_to_staff_ratio": "mean",
34
+ "service_coverage": "mean",
35
+ "interaction_rate": "mean",
36
+ "staff_utilization": "mean",
37
+ "area_utilization": "mean",
38
+ "service_quality_score": "mean",
39
+ "attention_score": "mean",
40
+ "overall_performance": "mean",
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class MetricAggregator:
46
+ """Stores aggregated values for a single metric."""
47
+ values: List[float] = field(default_factory=list)
48
+ agg_type: str = "mean"
49
+
50
+ def add_value(self, value: float):
51
+ """Add a value to the aggregator."""
52
+ if value is not None and isinstance(value, (int, float)):
53
+ self.values.append(float(value))
54
+
55
+ def get_aggregated_value(self) -> Optional[float]:
56
+ """Get the aggregated value based on aggregation type."""
57
+ if not self.values:
58
+ return None
59
+
60
+ if self.agg_type == "mean":
61
+ return sum(self.values) / len(self.values)
62
+ elif self.agg_type == "min":
63
+ return min(self.values)
64
+ elif self.agg_type == "max":
65
+ return max(self.values)
66
+ elif self.agg_type == "sum":
67
+ return sum(self.values)
68
+ else:
69
+ # Default to mean if unknown type
70
+ return sum(self.values) / len(self.values)
71
+
72
+ def reset(self):
73
+ """Reset the aggregator values."""
74
+ self.values = []
75
+
76
+ def has_values(self) -> bool:
77
+ """Check if aggregator has any values."""
78
+ return len(self.values) > 0
79
+
80
+
81
+ @dataclass
82
+ class CameraMetricsState:
83
+ """Stores metrics state for a camera."""
84
+ camera_id: str
85
+ camera_name: str = ""
86
+ app_deployment_id: str = ""
87
+ application_id: str = ""
88
+ metrics: Dict[str, MetricAggregator] = field(default_factory=dict)
89
+ last_push_time: float = field(default_factory=time.time)
90
+
91
+ def add_metric_value(self, metric_name: str, value: float, agg_type: str = "mean"):
92
+ """Add a value for a specific metric."""
93
+ if metric_name not in self.metrics:
94
+ self.metrics[metric_name] = MetricAggregator(agg_type=agg_type)
95
+ self.metrics[metric_name].add_value(value)
96
+
97
+ def get_aggregated_metrics(self) -> Dict[str, Dict[str, Any]]:
98
+ """Get all aggregated metrics in output format."""
99
+ result = {}
100
+ for metric_name, aggregator in self.metrics.items():
101
+ if aggregator.has_values():
102
+ agg_value = aggregator.get_aggregated_value()
103
+ if agg_value is not None:
104
+ result[metric_name] = {
105
+ "data": round(agg_value, 4),
106
+ "agg_type": aggregator.agg_type
107
+ }
108
+ return result
109
+
110
+ def reset_metrics(self):
111
+ """Reset all metric aggregators."""
112
+ for aggregator in self.metrics.values():
113
+ aggregator.reset()
114
+ self.last_push_time = time.time()
115
+
116
+ def has_metrics(self) -> bool:
117
+ """Check if any metrics have values."""
118
+ return any(agg.has_values() for agg in self.metrics.values())
119
+
120
+
121
+ class BUSINESS_METRICS_MANAGER:
122
+ """
123
+ Manages business metrics aggregation and publishing.
124
+
125
+ Key behaviors:
126
+ - Aggregates business metrics for configurable interval (default 5 minutes)
127
+ - Publishes aggregated metrics to Redis/Kafka topic
128
+ - Supports multiple aggregation types (mean, min, max, sum)
129
+ - Resets all values after publishing
130
+ - Thread-safe operations
131
+
132
+ Usage:
133
+ manager = BUSINESS_METRICS_MANAGER(redis_client=..., kafka_client=...)
134
+ manager.start() # Start aggregation timer
135
+ manager.process_metrics(camera_id, metrics_data, stream_info)
136
+ manager.stop() # Stop on shutdown
137
+ """
138
+
139
+ OUTPUT_TOPIC = "business_metrics"
140
+
141
+ def __init__(
142
+ self,
143
+ redis_client: Optional[Any] = None,
144
+ kafka_client: Optional[Any] = None,
145
+ output_topic: str = "business_metrics",
146
+ aggregation_interval: int = DEFAULT_AGGREGATION_INTERVAL,
147
+ metrics_config: Optional[Dict[str, str]] = None,
148
+ logger: Optional[logging.Logger] = None
149
+ ):
150
+ """
151
+ Initialize BUSINESS_METRICS_MANAGER.
152
+
153
+ Args:
154
+ redis_client: MatriceStream instance configured for Redis
155
+ kafka_client: MatriceStream instance configured for Kafka
156
+ output_topic: Topic/stream name for publishing metrics
157
+ aggregation_interval: Interval in seconds for aggregation (default 300 = 5 minutes)
158
+ metrics_config: Dict of metric_name -> aggregation_type
159
+ logger: Python logger instance
160
+ """
161
+ self.redis_client = redis_client
162
+ self.kafka_client = kafka_client
163
+ self.output_topic = output_topic
164
+ self.aggregation_interval = aggregation_interval
165
+ self.metrics_config = metrics_config or DEFAULT_METRICS_CONFIG.copy()
166
+ self.logger = logger or logging.getLogger(__name__)
167
+
168
+ # Per-camera metrics state tracking: {camera_id: CameraMetricsState}
169
+ self._camera_states: Dict[str, CameraMetricsState] = {}
170
+ self._states_lock = threading.Lock()
171
+
172
+ # Timer thread control
173
+ self._timer_thread: Optional[threading.Thread] = None
174
+ self._stop_event = threading.Event()
175
+ self._running = False
176
+
177
+ # Store factory reference for fetching camera info
178
+ self._factory_ref: Optional['BusinessMetricsManagerFactory'] = None
179
+
180
+ self.logger.info(
181
+ f"[BUSINESS_METRICS_MANAGER] Initialized with output_topic={output_topic}, "
182
+ f"aggregation_interval={aggregation_interval}s"
183
+ )
184
+
185
+ def set_factory_ref(self, factory: 'BusinessMetricsManagerFactory'):
186
+ """Set reference to factory for accessing deployment info."""
187
+ self._factory_ref = factory
188
+
189
+ def start(self):
190
+ """Start the background timer thread for periodic publishing."""
191
+ if self._running:
192
+ self.logger.warning("[BUSINESS_METRICS_MANAGER] Already running")
193
+ return
194
+
195
+ self._running = True
196
+ self._stop_event.clear()
197
+ self._timer_thread = threading.Thread(
198
+ target=self._timer_loop,
199
+ daemon=True,
200
+ name="BusinessMetricsTimer"
201
+ )
202
+ self._timer_thread.start()
203
+ self.logger.info("[BUSINESS_METRICS_MANAGER] ✓ Started timer thread")
204
+
205
+ def stop(self):
206
+ """Stop the background timer thread gracefully."""
207
+ if not self._running:
208
+ return
209
+
210
+ self.logger.info("[BUSINESS_METRICS_MANAGER] Stopping...")
211
+ self._running = False
212
+ self._stop_event.set()
213
+
214
+ if self._timer_thread and self._timer_thread.is_alive():
215
+ self._timer_thread.join(timeout=5)
216
+
217
+ self.logger.info("[BUSINESS_METRICS_MANAGER] ✓ Stopped")
218
+
219
+ def _timer_loop(self):
220
+ """Background thread that checks and publishes metrics periodically."""
221
+ self.logger.info(
222
+ f"[BUSINESS_METRICS_MANAGER] Timer loop started "
223
+ f"(interval: {self.aggregation_interval}s, check_every: 10s)"
224
+ )
225
+
226
+ loop_count = 0
227
+ while not self._stop_event.is_set():
228
+ loop_count += 1
229
+ try:
230
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Timer loop iteration #{loop_count}")
231
+ self._check_and_publish_all()
232
+ except Exception as e:
233
+ self.logger.error(
234
+ f"[BUSINESS_METRICS_MANAGER] Error in timer loop: {e}",
235
+ exc_info=True
236
+ )
237
+
238
+ # Sleep in small increments to allow quick shutdown
239
+ for _ in range(min(10, self.aggregation_interval)):
240
+ if self._stop_event.is_set():
241
+ break
242
+ time.sleep(1)
243
+
244
+ self.logger.info("[BUSINESS_METRICS_MANAGER] Timer loop exited")
245
+
246
+ def _check_and_publish_all(self):
247
+ """Check all cameras and publish metrics if interval has passed."""
248
+ current_time = time.time()
249
+ cameras_to_publish = []
250
+
251
+ with self._states_lock:
252
+ num_cameras = len(self._camera_states)
253
+ if num_cameras > 0:
254
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] _check_and_publish_all: checking {num_cameras} camera(s)")
255
+
256
+ for camera_id, state in self._camera_states.items():
257
+ elapsed = current_time - state.last_push_time
258
+ has_metrics = state.has_metrics()
259
+ metrics_count = sum(len(agg.values) for agg in state.metrics.values())
260
+
261
+ self.logger.debug(
262
+ f"[BUSINESS_METRICS_MANAGER] Camera {camera_id}: elapsed={elapsed:.1f}s, "
263
+ f"interval={self.aggregation_interval}s, has_metrics={has_metrics}, count={metrics_count}"
264
+ )
265
+
266
+ if elapsed >= self.aggregation_interval and has_metrics:
267
+ cameras_to_publish.append(camera_id)
268
+ self.logger.info(
269
+ f"[BUSINESS_METRICS_MANAGER] ✓ Camera {camera_id} ready for publish "
270
+ f"(elapsed={elapsed:.1f}s >= {self.aggregation_interval}s)"
271
+ )
272
+
273
+ if cameras_to_publish:
274
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] Publishing metrics for {len(cameras_to_publish)} camera(s)")
275
+
276
+ for camera_id in cameras_to_publish:
277
+ try:
278
+ success = self._publish_camera_metrics(camera_id)
279
+ if success:
280
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] ✓ Successfully published metrics for camera: {camera_id}")
281
+ else:
282
+ self.logger.warning(f"[BUSINESS_METRICS_MANAGER] ❌ Failed to publish metrics for camera: {camera_id}")
283
+ except Exception as e:
284
+ self.logger.error(
285
+ f"[BUSINESS_METRICS_MANAGER] Error publishing metrics for "
286
+ f"camera {camera_id}: {e}",
287
+ exc_info=True
288
+ )
289
+
290
+ def _extract_camera_info_from_stream(
291
+ self,
292
+ stream_info: Optional[Dict[str, Any]]
293
+ ) -> Dict[str, str]:
294
+ """
295
+ Extract camera info from stream_info.
296
+
297
+ Stream info structure example:
298
+ {
299
+ 'broker': 'localhost:9092',
300
+ 'topic': '692d7bde42582ffde3611908_input_topic', # camera_id is here!
301
+ 'stream_time': '2025-12-02-05:09:53.914224 UTC',
302
+ 'camera_info': {
303
+ 'camera_name': 'cusstomer-cam-1',
304
+ 'camera_group': 'staging-customer-1',
305
+ 'location': '6908756db129880c34f2e09a'
306
+ },
307
+ 'frame_id': '...'
308
+ }
309
+
310
+ Args:
311
+ stream_info: Stream metadata from usecase
312
+
313
+ Returns:
314
+ Dict with camera_id, camera_name, app_deployment_id, application_id
315
+ """
316
+ result = {
317
+ "camera_id": "",
318
+ "camera_name": "",
319
+ "app_deployment_id": "",
320
+ "application_id": ""
321
+ }
322
+
323
+ if not stream_info:
324
+ self.logger.debug("[BUSINESS_METRICS_MANAGER] _extract_camera_info_from_stream: stream_info is None/empty")
325
+ return result
326
+
327
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] _extract_camera_info_from_stream: stream_info keys = {list(stream_info.keys())}")
328
+
329
+ try:
330
+ # Try multiple paths to get camera info
331
+ # Path 1: Direct camera_info in stream_info (most common for streaming)
332
+ camera_info = stream_info.get("camera_info", {}) or {}
333
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Direct camera_info = {camera_info}")
334
+
335
+ # Path 2: From input_settings -> input_stream pattern
336
+ input_settings = stream_info.get("input_settings", {}) or {}
337
+ input_stream = input_settings.get("input_stream", {}) or {}
338
+ input_camera_info = input_stream.get("camera_info", {}) or {}
339
+
340
+ # Path 3: From input_streams array
341
+ input_streams = stream_info.get("input_streams", [])
342
+ if input_streams and len(input_streams) > 0:
343
+ input_data = input_streams[0] if isinstance(input_streams[0], dict) else {}
344
+ input_stream_inner = input_data.get("input_stream", input_data)
345
+ input_camera_info = input_stream_inner.get("camera_info", {}) or input_camera_info
346
+
347
+ # Path 4: Extract camera_id from topic field (e.g., "692d7bde42582ffde3611908_input_topic")
348
+ topic = stream_info.get("topic", "")
349
+ camera_id_from_topic = ""
350
+ if topic and "_input_topic" in topic:
351
+ camera_id_from_topic = topic.replace("_input_topic", "").strip()
352
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Extracted camera_id from topic: {camera_id_from_topic}")
353
+
354
+ # Merge all sources, preferring non-empty values
355
+ # camera_name - prefer camera_info.camera_name
356
+ result["camera_name"] = (
357
+ camera_info.get("camera_name", "") or
358
+ input_camera_info.get("camera_name", "") or
359
+ stream_info.get("camera_name", "") or
360
+ input_settings.get("camera_name", "") or
361
+ ""
362
+ )
363
+
364
+ # camera_id - try topic extraction first, then other sources
365
+ result["camera_id"] = (
366
+ camera_id_from_topic or
367
+ camera_info.get("camera_id", "") or
368
+ input_camera_info.get("camera_id", "") or
369
+ stream_info.get("camera_id", "") or
370
+ input_settings.get("camera_id", "") or
371
+ camera_info.get("cameraId", "") or
372
+ input_camera_info.get("cameraId", "") or
373
+ ""
374
+ )
375
+
376
+ # app_deployment_id
377
+ result["app_deployment_id"] = (
378
+ stream_info.get("app_deployment_id", "") or
379
+ stream_info.get("appDeploymentId", "") or
380
+ input_settings.get("app_deployment_id", "") or
381
+ input_settings.get("appDeploymentId", "") or
382
+ camera_info.get("app_deployment_id", "") or
383
+ ""
384
+ )
385
+
386
+ # application_id
387
+ result["application_id"] = (
388
+ stream_info.get("application_id", "") or
389
+ stream_info.get("applicationId", "") or
390
+ input_settings.get("application_id", "") or
391
+ input_settings.get("applicationId", "") or
392
+ camera_info.get("application_id", "") or
393
+ ""
394
+ )
395
+
396
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Extracted camera info: {result}")
397
+
398
+ except Exception as e:
399
+ self.logger.error(f"[BUSINESS_METRICS_MANAGER] Error extracting camera info: {e}", exc_info=True)
400
+
401
+ return result
402
+
403
+ def process_metrics(
404
+ self,
405
+ camera_id: str,
406
+ metrics_data: Dict[str, Any],
407
+ stream_info: Optional[Dict[str, Any]] = None
408
+ ) -> bool:
409
+ """
410
+ Process business metrics and add to aggregation.
411
+
412
+ This method:
413
+ 1. Extracts camera info from stream_info
414
+ 2. Adds each metric value to the appropriate aggregator
415
+ 3. Checks if aggregation interval has passed and publishes if so
416
+
417
+ Args:
418
+ camera_id: Unique camera identifier
419
+ metrics_data: Business metrics dictionary from usecase
420
+ stream_info: Stream metadata
421
+
422
+ Returns:
423
+ True if metrics were published, False otherwise
424
+ """
425
+ try:
426
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] ===== process_metrics START =====")
427
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Input camera_id param: {camera_id}")
428
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] metrics_data keys: {list(metrics_data.keys()) if metrics_data else 'None'}")
429
+
430
+ if not metrics_data or not isinstance(metrics_data, dict):
431
+ self.logger.debug("[BUSINESS_METRICS_MANAGER] Empty or invalid metrics data, skipping")
432
+ return False
433
+
434
+ # Extract camera info from stream_info
435
+ camera_info = self._extract_camera_info_from_stream(stream_info)
436
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Extracted camera_info: {camera_info}")
437
+
438
+ # Get factory app_deployment_id and application_id if available (from jobParams)
439
+ factory_app_deployment_id = ""
440
+ factory_application_id = ""
441
+ if self._factory_ref:
442
+ factory_app_deployment_id = self._factory_ref._app_deployment_id or ""
443
+ factory_application_id = self._factory_ref._application_id or ""
444
+ self.logger.debug(
445
+ f"[BUSINESS_METRICS_MANAGER] Factory values - "
446
+ f"app_deployment_id: {factory_app_deployment_id}, application_id: {factory_application_id}"
447
+ )
448
+
449
+ # Use extracted or fallback values
450
+ # Priority: stream_info > factory (from jobParams)
451
+ final_camera_id = camera_info.get("camera_id") or camera_id or ""
452
+ final_camera_name = camera_info.get("camera_name") or ""
453
+ final_app_deployment_id = camera_info.get("app_deployment_id") or factory_app_deployment_id or ""
454
+ final_application_id = camera_info.get("application_id") or factory_application_id or ""
455
+
456
+ self.logger.info(
457
+ f"[BUSINESS_METRICS_MANAGER] Final values - camera_id={final_camera_id}, "
458
+ f"camera_name={final_camera_name}, app_deployment_id={final_app_deployment_id}, "
459
+ f"application_id={final_application_id}"
460
+ )
461
+
462
+ with self._states_lock:
463
+ # Get or create state for this camera
464
+ if final_camera_id not in self._camera_states:
465
+ self._camera_states[final_camera_id] = CameraMetricsState(
466
+ camera_id=final_camera_id,
467
+ camera_name=final_camera_name,
468
+ app_deployment_id=final_app_deployment_id,
469
+ application_id=final_application_id
470
+ )
471
+ self.logger.info(
472
+ f"[BUSINESS_METRICS_MANAGER] ✓ Created new state for camera: {final_camera_id}"
473
+ )
474
+
475
+ state = self._camera_states[final_camera_id]
476
+
477
+ # Update camera info if we have better values
478
+ if final_camera_name and not state.camera_name:
479
+ state.camera_name = final_camera_name
480
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Updated camera_name to: {final_camera_name}")
481
+ if final_app_deployment_id and not state.app_deployment_id:
482
+ state.app_deployment_id = final_app_deployment_id
483
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Updated app_deployment_id to: {final_app_deployment_id}")
484
+ if final_application_id and not state.application_id:
485
+ state.application_id = final_application_id
486
+
487
+ # Add each metric value to aggregator
488
+ metrics_added = 0
489
+ for metric_name, value in metrics_data.items():
490
+ # Skip non-numeric fields and complex objects
491
+ if metric_name in ["peak_areas", "optimization_opportunities"]:
492
+ continue
493
+
494
+ # Handle area_utilization which is a dict
495
+ if metric_name == "area_utilization" and isinstance(value, dict):
496
+ # Average all area utilization values
497
+ area_values = [v for v in value.values() if isinstance(v, (int, float))]
498
+ if area_values:
499
+ value = sum(area_values) / len(area_values)
500
+ else:
501
+ continue
502
+
503
+ # Only process numeric values
504
+ if isinstance(value, (int, float)):
505
+ agg_type = self.metrics_config.get(metric_name, "mean")
506
+ with self._states_lock:
507
+ state.add_metric_value(metric_name, value, agg_type)
508
+ metrics_added += 1
509
+
510
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] Added {metrics_added} metric values to aggregator")
511
+
512
+ # Check if we should publish (interval elapsed)
513
+ current_time = time.time()
514
+ should_publish = False
515
+ elapsed = 0.0
516
+ metrics_count = 0
517
+
518
+ with self._states_lock:
519
+ elapsed = current_time - state.last_push_time
520
+ has_metrics = state.has_metrics()
521
+ metrics_count = sum(len(agg.values) for agg in state.metrics.values())
522
+
523
+ self.logger.debug(
524
+ f"[BUSINESS_METRICS_MANAGER] Publish check - elapsed={elapsed:.1f}s, "
525
+ f"interval={self.aggregation_interval}s, has_metrics={has_metrics}, "
526
+ f"total_values_count={metrics_count}"
527
+ )
528
+
529
+ if elapsed >= self.aggregation_interval and has_metrics:
530
+ should_publish = True
531
+ self.logger.info(
532
+ f"[BUSINESS_METRICS_MANAGER] ✓ PUBLISH CONDITION MET! "
533
+ f"elapsed={elapsed:.1f}s >= interval={self.aggregation_interval}s"
534
+ )
535
+ else:
536
+ remaining = self.aggregation_interval - elapsed
537
+ self.logger.debug(
538
+ f"[BUSINESS_METRICS_MANAGER] Not publishing yet. "
539
+ f"Remaining time: {remaining:.1f}s, metrics_count={metrics_count}"
540
+ )
541
+
542
+ if should_publish:
543
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] Triggering publish for camera: {final_camera_id}")
544
+ return self._publish_camera_metrics(final_camera_id)
545
+
546
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER] ===== process_metrics END (no publish) =====")
547
+ return False
548
+
549
+ except Exception as e:
550
+ self.logger.error(
551
+ f"[BUSINESS_METRICS_MANAGER] Error processing metrics: {e}",
552
+ exc_info=True
553
+ )
554
+ return False
555
+
556
+ def _publish_camera_metrics(self, camera_id: str) -> bool:
557
+ """
558
+ Publish aggregated metrics for a specific camera.
559
+
560
+ Args:
561
+ camera_id: Camera identifier
562
+
563
+ Returns:
564
+ True if published successfully, False otherwise
565
+ """
566
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] ========== PUBLISHING METRICS ==========")
567
+
568
+ try:
569
+ with self._states_lock:
570
+ if camera_id not in self._camera_states:
571
+ self.logger.warning(
572
+ f"[BUSINESS_METRICS_MANAGER] No state found for camera: {camera_id}"
573
+ )
574
+ return False
575
+
576
+ state = self._camera_states[camera_id]
577
+
578
+ if not state.has_metrics():
579
+ self.logger.debug(
580
+ f"[BUSINESS_METRICS_MANAGER] No metrics to publish for camera: {camera_id}"
581
+ )
582
+ return False
583
+
584
+ # Build the message
585
+ aggregated_metrics = state.get_aggregated_metrics()
586
+
587
+ message = {
588
+ "camera_id": state.camera_id,
589
+ "camera_name": state.camera_name,
590
+ "app_deployment_id": state.app_deployment_id,
591
+ "application_id": state.application_id,
592
+ "business_metrics": aggregated_metrics,
593
+ "timestamp": datetime.now(timezone.utc).isoformat(),
594
+ "aggregation_interval_seconds": self.aggregation_interval
595
+ }
596
+
597
+ # Reset metrics after building message (inside lock)
598
+ state.reset_metrics()
599
+
600
+ self.logger.info(
601
+ f"[BUSINESS_METRICS_MANAGER] Built metrics message: "
602
+ f"{json.dumps(message, default=str)[:500]}..."
603
+ )
604
+
605
+ success = False
606
+
607
+ # Try Redis first (primary)
608
+ if self.redis_client:
609
+ try:
610
+ self.logger.debug(
611
+ f"[BUSINESS_METRICS_MANAGER] Publishing to Redis stream: {self.output_topic}"
612
+ )
613
+ self._publish_to_redis(self.output_topic, message)
614
+ self.logger.info(
615
+ f"[BUSINESS_METRICS_MANAGER] ✓ Metrics published to Redis"
616
+ )
617
+ success = True
618
+ except Exception as e:
619
+ self.logger.error(
620
+ f"[BUSINESS_METRICS_MANAGER] ❌ Redis publish failed: {e}",
621
+ exc_info=True
622
+ )
623
+
624
+ # Fallback to Kafka if Redis failed or no Redis client
625
+ if not success and self.kafka_client:
626
+ try:
627
+ self.logger.debug(
628
+ f"[BUSINESS_METRICS_MANAGER] Publishing to Kafka topic: {self.output_topic}"
629
+ )
630
+ self._publish_to_kafka(self.output_topic, message)
631
+ self.logger.info(
632
+ f"[BUSINESS_METRICS_MANAGER] ✓ Metrics published to Kafka"
633
+ )
634
+ success = True
635
+ except Exception as e:
636
+ self.logger.error(
637
+ f"[BUSINESS_METRICS_MANAGER] ❌ Kafka publish failed: {e}",
638
+ exc_info=True
639
+ )
640
+
641
+ if success:
642
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] ========== METRICS PUBLISHED ==========")
643
+ else:
644
+ self.logger.error(
645
+ f"[BUSINESS_METRICS_MANAGER] ❌ METRICS NOT PUBLISHED (both transports failed)"
646
+ )
647
+
648
+ return success
649
+
650
+ except Exception as e:
651
+ self.logger.error(
652
+ f"[BUSINESS_METRICS_MANAGER] Error publishing metrics: {e}",
653
+ exc_info=True
654
+ )
655
+ return False
656
+
657
+ def _publish_to_redis(self, topic: str, message: Dict[str, Any]):
658
+ """Publish message to Redis stream."""
659
+ try:
660
+ self.redis_client.add_message(
661
+ topic_or_channel=topic,
662
+ message=json.dumps(message),
663
+ key=message.get("camera_id", "")
664
+ )
665
+ except Exception as e:
666
+ self.logger.error(f"[BUSINESS_METRICS_MANAGER] Redis publish error: {e}")
667
+ raise
668
+
669
+ def _publish_to_kafka(self, topic: str, message: Dict[str, Any]):
670
+ """Publish message to Kafka topic."""
671
+ try:
672
+ self.kafka_client.add_message(
673
+ topic_or_channel=topic,
674
+ message=json.dumps(message),
675
+ key=message.get("camera_id", "")
676
+ )
677
+ except Exception as e:
678
+ self.logger.error(f"[BUSINESS_METRICS_MANAGER] Kafka publish error: {e}")
679
+ raise
680
+
681
+ def reset_camera_state(self, camera_id: str):
682
+ """Reset metrics state for a specific camera."""
683
+ with self._states_lock:
684
+ if camera_id in self._camera_states:
685
+ self._camera_states[camera_id].reset_metrics()
686
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] Reset state for camera: {camera_id}")
687
+
688
+ def get_camera_state(self, camera_id: str) -> Optional[Dict[str, Any]]:
689
+ """Get current metrics state for a camera (for debugging)."""
690
+ with self._states_lock:
691
+ state = self._camera_states.get(camera_id)
692
+ if state:
693
+ return {
694
+ "camera_id": state.camera_id,
695
+ "camera_name": state.camera_name,
696
+ "app_deployment_id": state.app_deployment_id,
697
+ "application_id": state.application_id,
698
+ "metrics_count": {
699
+ name: len(agg.values)
700
+ for name, agg in state.metrics.items()
701
+ },
702
+ "last_push_time": state.last_push_time,
703
+ "seconds_since_push": time.time() - state.last_push_time
704
+ }
705
+ return None
706
+
707
+ def get_all_camera_states(self) -> Dict[str, Dict[str, Any]]:
708
+ """Get all camera states for debugging/monitoring."""
709
+ with self._states_lock:
710
+ return {
711
+ cam_id: {
712
+ "camera_id": state.camera_id,
713
+ "camera_name": state.camera_name,
714
+ "metrics_count": {
715
+ name: len(agg.values)
716
+ for name, agg in state.metrics.items()
717
+ },
718
+ "last_push_time": state.last_push_time,
719
+ "seconds_since_push": time.time() - state.last_push_time
720
+ }
721
+ for cam_id, state in self._camera_states.items()
722
+ }
723
+
724
+ def force_publish_all(self) -> int:
725
+ """Force publish all cameras with pending metrics. Returns count published."""
726
+ published_count = 0
727
+ # Collect camera IDs with pending metrics without holding the lock during publish
728
+ with self._states_lock:
729
+ camera_ids = [cam_id for cam_id, state in self._camera_states.items() if state.has_metrics()]
730
+ for camera_id in camera_ids:
731
+ if self._publish_camera_metrics(camera_id):
732
+ published_count += 1
733
+ return published_count
734
+
735
+ def set_metrics_config(self, metrics_config: Dict[str, str]):
736
+ """
737
+ Set aggregation type configuration for metrics.
738
+
739
+ Args:
740
+ metrics_config: Dict of metric_name -> aggregation_type
741
+ """
742
+ self.metrics_config = metrics_config
743
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER] Updated metrics config: {metrics_config}")
744
+
745
+ def set_aggregation_interval(self, interval_seconds: int):
746
+ """
747
+ Set the aggregation interval.
748
+
749
+ Args:
750
+ interval_seconds: New interval in seconds
751
+ """
752
+ self.aggregation_interval = interval_seconds
753
+ self.logger.info(
754
+ f"[BUSINESS_METRICS_MANAGER] Updated aggregation interval to {interval_seconds}s"
755
+ )
756
+
757
+
758
+ class BusinessMetricsManagerFactory:
759
+ """
760
+ Factory class for creating BUSINESS_METRICS_MANAGER instances.
761
+
762
+ Handles session initialization and Redis/Kafka client creation
763
+ following the same pattern as IncidentManagerFactory.
764
+ """
765
+
766
+ ACTION_ID_PATTERN = re.compile(r"^[0-9a-f]{8,}$", re.IGNORECASE)
767
+
768
+ def __init__(self, logger: Optional[logging.Logger] = None):
769
+ self.logger = logger or logging.getLogger(__name__)
770
+ self._initialized = False
771
+ self._business_metrics_manager: Optional[BUSINESS_METRICS_MANAGER] = None
772
+
773
+ # Store these for later access
774
+ self._session = None
775
+ self._action_id: Optional[str] = None
776
+ self._instance_id: Optional[str] = None
777
+ self._deployment_id: Optional[str] = None
778
+ self._app_deployment_id: Optional[str] = None
779
+ self._application_id: Optional[str] = None # Store application_id from jobParams
780
+ self._external_ip: Optional[str] = None
781
+
782
+ def initialize(
783
+ self,
784
+ config: Any,
785
+ aggregation_interval: int = DEFAULT_AGGREGATION_INTERVAL,
786
+ metrics_config: Optional[Dict[str, str]] = None
787
+ ) -> Optional[BUSINESS_METRICS_MANAGER]:
788
+ """
789
+ Initialize and return BUSINESS_METRICS_MANAGER with Redis/Kafka clients.
790
+
791
+ This follows the same pattern as IncidentManagerFactory for
792
+ session initialization and Redis/Kafka client creation.
793
+
794
+ Args:
795
+ config: Configuration object with session, server_id, etc.
796
+ aggregation_interval: Interval in seconds for aggregation (default 300)
797
+ metrics_config: Dict of metric_name -> aggregation_type
798
+
799
+ Returns:
800
+ BUSINESS_METRICS_MANAGER instance or None if initialization failed
801
+ """
802
+ if self._initialized and self._business_metrics_manager is not None:
803
+ self.logger.debug(
804
+ "[BUSINESS_METRICS_MANAGER_FACTORY] Already initialized, returning existing instance"
805
+ )
806
+ return self._business_metrics_manager
807
+
808
+ try:
809
+ # Import required modules
810
+ from matrice_common.stream.matrice_stream import MatriceStream, StreamType
811
+ from matrice_common.session import Session
812
+
813
+ self.logger.info("[BUSINESS_METRICS_MANAGER_FACTORY] ===== STARTING INITIALIZATION =====")
814
+
815
+ # Get or create session
816
+ self._session = getattr(config, 'session', None)
817
+ if not self._session:
818
+ self.logger.info(
819
+ "[BUSINESS_METRICS_MANAGER_FACTORY] No session in config, creating from environment..."
820
+ )
821
+ account_number = os.getenv("MATRICE_ACCOUNT_NUMBER", "")
822
+ access_key_id = os.getenv("MATRICE_ACCESS_KEY_ID", "")
823
+ secret_key = os.getenv("MATRICE_SECRET_ACCESS_KEY", "")
824
+ project_id = os.getenv("MATRICE_PROJECT_ID", "")
825
+
826
+ self.logger.debug(
827
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Env vars - "
828
+ f"account: {'SET' if account_number else 'NOT SET'}, "
829
+ f"access_key: {'SET' if access_key_id else 'NOT SET'}, "
830
+ f"secret: {'SET' if secret_key else 'NOT SET'}"
831
+ )
832
+
833
+ self._session = Session(
834
+ account_number=account_number,
835
+ access_key=access_key_id,
836
+ secret_key=secret_key,
837
+ project_id=project_id,
838
+ )
839
+ self.logger.info("[BUSINESS_METRICS_MANAGER_FACTORY] ✓ Created session from environment")
840
+ else:
841
+ self.logger.info("[BUSINESS_METRICS_MANAGER_FACTORY] ✓ Using session from config")
842
+
843
+ rpc = self._session.rpc
844
+
845
+ # Discover action_id
846
+ self._action_id = self._discover_action_id()
847
+ if not self._action_id:
848
+ self.logger.error("[BUSINESS_METRICS_MANAGER_FACTORY] ❌ Could not discover action_id")
849
+ print("----- BUSINESS METRICS MANAGER ACTION DISCOVERY -----")
850
+ print("action_id: NOT FOUND")
851
+ print("------------------------------------------------------")
852
+ self._initialized = True
853
+ return None
854
+
855
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER_FACTORY] ✓ Discovered action_id: {self._action_id}")
856
+
857
+ # Fetch action details
858
+ action_details = {}
859
+ try:
860
+ action_url = f"/v1/actions/action/{self._action_id}/details"
861
+ action_resp = rpc.get(action_url)
862
+ if not (action_resp and action_resp.get("success", False)):
863
+ raise RuntimeError(
864
+ action_resp.get("message", "Unknown error")
865
+ if isinstance(action_resp, dict) else "Unknown error"
866
+ )
867
+ action_doc = action_resp.get("data", {}) if isinstance(action_resp, dict) else {}
868
+ action_details = action_doc.get("actionDetails", {}) if isinstance(action_doc, dict) else {}
869
+
870
+ # IMPORTANT: jobParams contains application_id
871
+ # Structure: response['data']['jobParams']['application_id']
872
+ job_params = action_doc.get("jobParams", {}) if isinstance(action_doc, dict) else {}
873
+
874
+ # Extract server details
875
+ server_id = (
876
+ action_details.get("serverId")
877
+ or action_details.get("server_id")
878
+ or action_details.get("serverID")
879
+ or action_details.get("redis_server_id")
880
+ or action_details.get("kafka_server_id")
881
+ )
882
+ server_type = (
883
+ action_details.get("serverType")
884
+ or action_details.get("server_type")
885
+ or action_details.get("type")
886
+ )
887
+
888
+ # Store identifiers
889
+ self._deployment_id = action_details.get("_idDeployment") or action_details.get("deployment_id")
890
+
891
+ # app_deployment_id: check actionDetails first, then jobParams
892
+ self._app_deployment_id = (
893
+ action_details.get("app_deployment_id") or
894
+ action_details.get("appDeploymentId") or
895
+ action_details.get("app_deploymentId") or
896
+ job_params.get("app_deployment_id") or
897
+ job_params.get("appDeploymentId") or
898
+ job_params.get("app_deploymentId") or
899
+ ""
900
+ )
901
+
902
+ # application_id: PRIMARILY from jobParams (this is where it lives!)
903
+ # response['data']['jobParams'].get('application_id', '')
904
+ self._application_id = (
905
+ job_params.get("application_id") or
906
+ job_params.get("applicationId") or
907
+ job_params.get("app_id") or
908
+ job_params.get("appId") or
909
+ action_details.get("application_id") or
910
+ action_details.get("applicationId") or
911
+ ""
912
+ )
913
+
914
+ self._instance_id = action_details.get("instanceID") or action_details.get("instanceId")
915
+ self._external_ip = action_details.get("externalIP") or action_details.get("externalIp")
916
+
917
+ print("----- BUSINESS METRICS MANAGER ACTION DETAILS -----")
918
+ print(f"action_id: {self._action_id}")
919
+ print(f"server_type: {server_type}")
920
+ print(f"server_id: {server_id}")
921
+ print(f"deployment_id: {self._deployment_id}")
922
+ print(f"app_deployment_id: {self._app_deployment_id}")
923
+ print(f"application_id: {self._application_id}")
924
+ print(f"instance_id: {self._instance_id}")
925
+ print(f"external_ip: {self._external_ip}")
926
+ print(f"jobParams keys: {list(job_params.keys()) if job_params else []}")
927
+ print("----------------------------------------------------")
928
+
929
+ self.logger.info(
930
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Action details - server_type={server_type}, "
931
+ f"instance_id={self._instance_id}, "
932
+ f"app_deployment_id={self._app_deployment_id}, application_id={self._application_id}"
933
+ )
934
+
935
+ # Log all available keys for debugging
936
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER_FACTORY] actionDetails keys: {list(action_details.keys())}")
937
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER_FACTORY] jobParams keys: {list(job_params.keys()) if job_params else []}")
938
+
939
+ except Exception as e:
940
+ self.logger.error(
941
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] ❌ Failed to fetch action details: {e}",
942
+ exc_info=True
943
+ )
944
+ print("----- BUSINESS METRICS MANAGER ACTION DETAILS ERROR -----")
945
+ print(f"action_id: {self._action_id}")
946
+ print(f"error: {e}")
947
+ print("---------------------------------------------------------")
948
+ self._initialized = True
949
+ return None
950
+
951
+ # Determine localhost vs cloud using externalIP from action_details
952
+ is_localhost = False
953
+ public_ip = self._get_public_ip()
954
+
955
+ # Get server host from action_details
956
+ server_host = (
957
+ action_details.get("externalIP")
958
+ or action_details.get("external_IP")
959
+ or action_details.get("externalip")
960
+ or action_details.get("external_ip")
961
+ or action_details.get("externalIp")
962
+ or action_details.get("external_Ip")
963
+ )
964
+ print(f"server_host: {server_host}")
965
+ self.logger.info(f"[BUSINESS_METRICS_MANAGER_FACTORY] DEBUG - server_host: {server_host}")
966
+
967
+ localhost_indicators = ["localhost", "127.0.0.1", "0.0.0.0"]
968
+ if server_host in localhost_indicators:
969
+ is_localhost = True
970
+ self.logger.info(
971
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Detected Localhost environment "
972
+ f"(Public IP={public_ip}, Server IP={server_host})"
973
+ )
974
+ else:
975
+ is_localhost = False
976
+ self.logger.info(
977
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Detected Cloud environment "
978
+ f"(Public IP={public_ip}, Server IP={server_host})"
979
+ )
980
+
981
+ redis_client = None
982
+ kafka_client = None
983
+
984
+ # STRICT SWITCH: Only Redis if localhost, Only Kafka if cloud
985
+ if is_localhost:
986
+ # Initialize Redis client (ONLY) using instance_id
987
+ if not self._instance_id:
988
+ self.logger.error(
989
+ "[BUSINESS_METRICS_MANAGER_FACTORY] ❌ Localhost mode but instance_id missing"
990
+ )
991
+ else:
992
+ try:
993
+ url = f"/v1/actions/get_redis_server_by_instance_id/{self._instance_id}"
994
+ self.logger.info(
995
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Fetching Redis server info "
996
+ f"for instance: {self._instance_id}"
997
+ )
998
+ response = rpc.get(url)
999
+
1000
+ if isinstance(response, dict) and response.get("success", False):
1001
+ data = response.get("data", {})
1002
+ host = data.get("host")
1003
+ port = data.get("port")
1004
+ username = data.get("username")
1005
+ password = data.get("password", "")
1006
+ db_index = data.get("db", 0)
1007
+ conn_timeout = data.get("connection_timeout", 120)
1008
+
1009
+ print("----- BUSINESS METRICS MANAGER REDIS SERVER PARAMS -----")
1010
+ print(f"instance_id: {self._instance_id}")
1011
+ print(f"host: {host}")
1012
+ print(f"port: {port}")
1013
+ print(f"username: {username}")
1014
+ print(f"password: {'*' * len(password) if password else ''}")
1015
+ print(f"db: {db_index}")
1016
+ print(f"connection_timeout: {conn_timeout}")
1017
+ print("--------------------------------------------------------")
1018
+
1019
+ self.logger.info(
1020
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Redis params - "
1021
+ f"host={host}, port={port}, user={username}"
1022
+ )
1023
+
1024
+ redis_client = MatriceStream(
1025
+ StreamType.REDIS,
1026
+ host=host,
1027
+ port=int(port),
1028
+ password=password,
1029
+ username=username,
1030
+ db=db_index,
1031
+ connection_timeout=conn_timeout
1032
+ )
1033
+ # Setup for metrics publishing
1034
+ redis_client.setup("business_metrics")
1035
+ self.logger.info("[BUSINESS_METRICS_MANAGER_FACTORY] ✓ Redis client initialized")
1036
+ else:
1037
+ self.logger.warning(
1038
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Failed to fetch Redis server info: "
1039
+ f"{response.get('message', 'Unknown error') if isinstance(response, dict) else 'Unknown error'}"
1040
+ )
1041
+ except Exception as e:
1042
+ self.logger.warning(
1043
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Redis initialization failed: {e}"
1044
+ )
1045
+
1046
+ else:
1047
+ # Initialize Kafka client (ONLY) using global info endpoint
1048
+ try:
1049
+ url = f"/v1/actions/get_kafka_info"
1050
+ self.logger.info(
1051
+ "[BUSINESS_METRICS_MANAGER_FACTORY] Fetching Kafka server info for Cloud mode"
1052
+ )
1053
+ response = rpc.get(url)
1054
+
1055
+ if isinstance(response, dict) and response.get("success", False):
1056
+ data = response.get("data", {})
1057
+ enc_ip = data.get("ip")
1058
+ enc_port = data.get("port")
1059
+
1060
+ # Decode base64 encoded values
1061
+ ip_addr = None
1062
+ port = None
1063
+ try:
1064
+ ip_addr = base64.b64decode(str(enc_ip)).decode("utf-8")
1065
+ except Exception:
1066
+ ip_addr = enc_ip
1067
+ try:
1068
+ port = base64.b64decode(str(enc_port)).decode("utf-8")
1069
+ except Exception:
1070
+ port = enc_port
1071
+
1072
+ print("----- BUSINESS METRICS MANAGER KAFKA SERVER PARAMS -----")
1073
+ print(f"ipAddress: {ip_addr}")
1074
+ print(f"port: {port}")
1075
+ print("--------------------------------------------------------")
1076
+
1077
+ self.logger.info(
1078
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Kafka params - ip={ip_addr}, port={port}"
1079
+ )
1080
+
1081
+ bootstrap_servers = f"{ip_addr}:{port}"
1082
+ kafka_client = MatriceStream(
1083
+ StreamType.KAFKA,
1084
+ bootstrap_servers=bootstrap_servers,
1085
+ sasl_mechanism="SCRAM-SHA-256",
1086
+ sasl_username="matrice-sdk-user",
1087
+ sasl_password="matrice-sdk-password",
1088
+ security_protocol="SASL_PLAINTEXT"
1089
+ )
1090
+ # Setup for metrics publishing (producer-only; no consumer group needed)
1091
+ kafka_client.setup("business_metrics")
1092
+ self.logger.info(
1093
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] ✓ Kafka client initialized "
1094
+ f"(servers={bootstrap_servers})"
1095
+ )
1096
+ else:
1097
+ self.logger.warning(
1098
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Failed to fetch Kafka server info: "
1099
+ f"{response.get('message', 'Unknown error') if isinstance(response, dict) else 'Unknown error'}"
1100
+ )
1101
+ except Exception as e:
1102
+ self.logger.warning(
1103
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Kafka initialization failed: {e}"
1104
+ )
1105
+
1106
+ # Create business metrics manager if we have at least one transport
1107
+ if redis_client or kafka_client:
1108
+ self._business_metrics_manager = BUSINESS_METRICS_MANAGER(
1109
+ redis_client=redis_client,
1110
+ kafka_client=kafka_client,
1111
+ output_topic="business_metrics",
1112
+ aggregation_interval=aggregation_interval,
1113
+ metrics_config=metrics_config or DEFAULT_METRICS_CONFIG.copy(),
1114
+ logger=self.logger
1115
+ )
1116
+ # Set factory reference for accessing deployment info
1117
+ self._business_metrics_manager.set_factory_ref(self)
1118
+ # Start the timer thread
1119
+ self._business_metrics_manager.start()
1120
+
1121
+ transport = "Redis" if redis_client else "Kafka"
1122
+ self.logger.info(
1123
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] ✓ Business metrics manager created with {transport}"
1124
+ )
1125
+ print(f"----- BUSINESS METRICS MANAGER INITIALIZED ({transport}) -----")
1126
+ else:
1127
+ self.logger.warning(
1128
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] No {'Redis' if is_localhost else 'Kafka'} client available, "
1129
+ f"business metrics manager not created"
1130
+ )
1131
+
1132
+ self._initialized = True
1133
+ self.logger.info("[BUSINESS_METRICS_MANAGER_FACTORY] ===== INITIALIZATION COMPLETE =====")
1134
+ return self._business_metrics_manager
1135
+
1136
+ except ImportError as e:
1137
+ self.logger.error(f"[BUSINESS_METRICS_MANAGER_FACTORY] Import error: {e}")
1138
+ self._initialized = True
1139
+ return None
1140
+ except Exception as e:
1141
+ self.logger.error(
1142
+ f"[BUSINESS_METRICS_MANAGER_FACTORY] Initialization failed: {e}",
1143
+ exc_info=True
1144
+ )
1145
+ self._initialized = True
1146
+ return None
1147
+
1148
+ def _discover_action_id(self) -> Optional[str]:
1149
+ """Discover action_id from current working directory name (and parents)."""
1150
+ try:
1151
+ candidates: List[str] = []
1152
+
1153
+ try:
1154
+ cwd = Path.cwd()
1155
+ candidates.append(cwd.name)
1156
+ for parent in cwd.parents:
1157
+ candidates.append(parent.name)
1158
+ except Exception:
1159
+ pass
1160
+
1161
+ try:
1162
+ usr_src = Path("/usr/src")
1163
+ if usr_src.exists():
1164
+ for child in usr_src.iterdir():
1165
+ if child.is_dir():
1166
+ candidates.append(child.name)
1167
+ except Exception:
1168
+ pass
1169
+
1170
+ for candidate in candidates:
1171
+ if candidate and len(candidate) >= 8 and self.ACTION_ID_PATTERN.match(candidate):
1172
+ return candidate
1173
+ except Exception:
1174
+ pass
1175
+ return None
1176
+
1177
+ def _get_public_ip(self) -> str:
1178
+ """Get the public IP address of this machine."""
1179
+ self.logger.info("[BUSINESS_METRICS_MANAGER_FACTORY] Fetching public IP address...")
1180
+ try:
1181
+ public_ip = urllib.request.urlopen(
1182
+ "https://v4.ident.me", timeout=120
1183
+ ).read().decode("utf8").strip()
1184
+ self.logger.debug(f"[BUSINESS_METRICS_MANAGER_FACTORY] Public IP: {public_ip}")
1185
+ return public_ip
1186
+ except Exception as e:
1187
+ self.logger.warning(f"[BUSINESS_METRICS_MANAGER_FACTORY] Error fetching public IP: {e}")
1188
+ return "localhost"
1189
+
1190
+ def _get_backend_base_url(self) -> str:
1191
+ """Resolve backend base URL based on ENV variable."""
1192
+ env = os.getenv("ENV", "prod").strip().lower()
1193
+ if env in ("prod", "production"):
1194
+ host = "prod.backend.app.matrice.ai"
1195
+ elif env in ("dev", "development"):
1196
+ host = "dev.backend.app.matrice.ai"
1197
+ else:
1198
+ host = "staging.backend.app.matrice.ai"
1199
+ return f"https://{host}"
1200
+
1201
+ @property
1202
+ def is_initialized(self) -> bool:
1203
+ return self._initialized
1204
+
1205
+ @property
1206
+ def business_metrics_manager(self) -> Optional[BUSINESS_METRICS_MANAGER]:
1207
+ return self._business_metrics_manager
1208
+
1209
+
1210
+ # Module-level factory instance for convenience
1211
+ _default_factory: Optional[BusinessMetricsManagerFactory] = None
1212
+
1213
+
1214
+ def get_business_metrics_manager(
1215
+ config: Any,
1216
+ logger: Optional[logging.Logger] = None,
1217
+ aggregation_interval: int = DEFAULT_AGGREGATION_INTERVAL,
1218
+ metrics_config: Optional[Dict[str, str]] = None
1219
+ ) -> Optional[BUSINESS_METRICS_MANAGER]:
1220
+ """
1221
+ Get or create BUSINESS_METRICS_MANAGER instance.
1222
+
1223
+ This is a convenience function that uses a module-level factory.
1224
+ For more control, use BusinessMetricsManagerFactory directly.
1225
+
1226
+ Args:
1227
+ config: Configuration object with session, server_id, etc.
1228
+ logger: Logger instance
1229
+ aggregation_interval: Interval in seconds for aggregation (default 300)
1230
+ metrics_config: Dict of metric_name -> aggregation_type
1231
+
1232
+ Returns:
1233
+ BUSINESS_METRICS_MANAGER instance or None
1234
+ """
1235
+ global _default_factory
1236
+
1237
+ if _default_factory is None:
1238
+ _default_factory = BusinessMetricsManagerFactory(logger=logger)
1239
+
1240
+ return _default_factory.initialize(
1241
+ config,
1242
+ aggregation_interval=aggregation_interval,
1243
+ metrics_config=metrics_config
1244
+ )
1245
+