emergent-translator 1.1.0__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.
@@ -0,0 +1,436 @@
1
+ """
2
+ Emergent Language Translator - Metrics & Event Emission
3
+
4
+ Real-time metrics collection and event broadcasting for stress testing.
5
+ Supports:
6
+ - Structured logging with JSON output
7
+ - PartyKit WebSocket event emission
8
+ - Prometheus-compatible metrics
9
+ - Real-time dashboard updates
10
+
11
+ Usage:
12
+ from .metrics import MetricsCollector, emit_event
13
+
14
+ metrics = MetricsCollector()
15
+ metrics.record_request(latency_ms=15.2, success=True, compression_ratio=0.016)
16
+
17
+ await emit_event("stress_test", {"instance": "vps-1", "rps": 150})
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import time
24
+ from dataclasses import dataclass, field, asdict
25
+ from datetime import datetime
26
+ from typing import Dict, Any, Optional, List
27
+ from collections import deque
28
+ import os
29
+
30
+ import httpx
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ @dataclass
36
+ class RequestMetric:
37
+ """Single request metric."""
38
+ timestamp: float
39
+ latency_ms: float
40
+ success: bool
41
+ original_size: int = 0
42
+ compressed_size: int = 0
43
+ compression_ratio: float = 0.0
44
+ endpoint: str = "/translate"
45
+ client_ip: str = "unknown"
46
+ error: Optional[str] = None
47
+ cache_hit: bool = False
48
+
49
+
50
+ @dataclass
51
+ class AggregatedMetrics:
52
+ """Aggregated metrics for a time window."""
53
+ window_start: float
54
+ window_end: float
55
+ total_requests: int
56
+ successful_requests: int
57
+ failed_requests: int
58
+ requests_per_second: float
59
+
60
+ latency_min_ms: float
61
+ latency_max_ms: float
62
+ latency_avg_ms: float
63
+ latency_p50_ms: float
64
+ latency_p95_ms: float
65
+ latency_p99_ms: float
66
+
67
+ avg_compression_ratio: float
68
+ total_bytes_in: int
69
+ total_bytes_out: int
70
+ bandwidth_savings_percent: float
71
+
72
+
73
+ class MetricsCollector:
74
+ """
75
+ Collects and aggregates metrics for stress testing.
76
+
77
+ Features:
78
+ - Rolling window metrics
79
+ - Real-time event emission
80
+ - PartyKit integration
81
+ - Prometheus export
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ window_size: int = 60, # seconds
87
+ partykit_url: Optional[str] = None,
88
+ instance_id: str = "primary"
89
+ ):
90
+ self.window_size = window_size
91
+ self.partykit_url = partykit_url or os.getenv("PARTYKIT_URL")
92
+ self.instance_id = instance_id
93
+
94
+ # Rolling metrics storage
95
+ self.recent_requests: deque = deque(maxlen=10000)
96
+ self.start_time = time.time()
97
+
98
+ # Counters
99
+ self.total_requests = 0
100
+ self.total_successful = 0
101
+ self.total_failed = 0
102
+ self.total_bytes_in = 0
103
+ self.total_bytes_out = 0
104
+
105
+ # HTTP client for PartyKit
106
+ self._http_client: Optional[httpx.AsyncClient] = None
107
+
108
+ async def _get_client(self) -> httpx.AsyncClient:
109
+ """Get or create HTTP client."""
110
+ if self._http_client is None:
111
+ self._http_client = httpx.AsyncClient(timeout=5.0)
112
+ return self._http_client
113
+
114
+ async def close(self):
115
+ """Close HTTP client."""
116
+ if self._http_client:
117
+ await self._http_client.aclose()
118
+ self._http_client = None
119
+
120
+ def record_request(
121
+ self,
122
+ latency_ms: float,
123
+ success: bool,
124
+ original_size: int = 0,
125
+ compressed_size: int = 0,
126
+ compression_ratio: float = 0.0,
127
+ endpoint: str = "/translate",
128
+ client_ip: str = "unknown",
129
+ error: Optional[str] = None,
130
+ cache_hit: bool = False
131
+ ):
132
+ """Record a single request metric."""
133
+ metric = RequestMetric(
134
+ timestamp=time.time(),
135
+ latency_ms=latency_ms,
136
+ success=success,
137
+ original_size=original_size,
138
+ compressed_size=compressed_size,
139
+ compression_ratio=compression_ratio,
140
+ endpoint=endpoint,
141
+ client_ip=client_ip,
142
+ error=error,
143
+ cache_hit=cache_hit
144
+ )
145
+
146
+ self.recent_requests.append(metric)
147
+ self.total_requests += 1
148
+
149
+ if success:
150
+ self.total_successful += 1
151
+ self.total_bytes_in += original_size
152
+ self.total_bytes_out += compressed_size
153
+ else:
154
+ self.total_failed += 1
155
+
156
+ def get_window_metrics(self, window_seconds: Optional[int] = None) -> AggregatedMetrics:
157
+ """Get aggregated metrics for a time window."""
158
+ window = window_seconds or self.window_size
159
+ now = time.time()
160
+ cutoff = now - window
161
+
162
+ # Filter to window
163
+ window_requests = [r for r in self.recent_requests if r.timestamp >= cutoff]
164
+
165
+ if not window_requests:
166
+ return AggregatedMetrics(
167
+ window_start=cutoff,
168
+ window_end=now,
169
+ total_requests=0,
170
+ successful_requests=0,
171
+ failed_requests=0,
172
+ requests_per_second=0,
173
+ latency_min_ms=0,
174
+ latency_max_ms=0,
175
+ latency_avg_ms=0,
176
+ latency_p50_ms=0,
177
+ latency_p95_ms=0,
178
+ latency_p99_ms=0,
179
+ avg_compression_ratio=0,
180
+ total_bytes_in=0,
181
+ total_bytes_out=0,
182
+ bandwidth_savings_percent=0
183
+ )
184
+
185
+ successful = [r for r in window_requests if r.success]
186
+ failed = [r for r in window_requests if not r.success]
187
+
188
+ # Latency percentiles
189
+ latencies = sorted([r.latency_ms for r in window_requests])
190
+ n = len(latencies)
191
+
192
+ bytes_in = sum(r.original_size for r in successful)
193
+ bytes_out = sum(r.compressed_size for r in successful)
194
+
195
+ return AggregatedMetrics(
196
+ window_start=cutoff,
197
+ window_end=now,
198
+ total_requests=len(window_requests),
199
+ successful_requests=len(successful),
200
+ failed_requests=len(failed),
201
+ requests_per_second=len(window_requests) / window,
202
+ latency_min_ms=min(latencies),
203
+ latency_max_ms=max(latencies),
204
+ latency_avg_ms=sum(latencies) / n,
205
+ latency_p50_ms=latencies[int(n * 0.5)],
206
+ latency_p95_ms=latencies[int(n * 0.95)] if n > 1 else latencies[-1],
207
+ latency_p99_ms=latencies[int(n * 0.99)] if n > 1 else latencies[-1],
208
+ avg_compression_ratio=sum(r.compression_ratio for r in successful) / len(successful) if successful else 0,
209
+ total_bytes_in=bytes_in,
210
+ total_bytes_out=bytes_out,
211
+ bandwidth_savings_percent=((bytes_in - bytes_out) / bytes_in * 100) if bytes_in > 0 else 0
212
+ )
213
+
214
+ def get_lifetime_stats(self) -> Dict[str, Any]:
215
+ """Get lifetime statistics."""
216
+ uptime = time.time() - self.start_time
217
+ return {
218
+ "instance_id": self.instance_id,
219
+ "uptime_seconds": uptime,
220
+ "total_requests": self.total_requests,
221
+ "total_successful": self.total_successful,
222
+ "total_failed": self.total_failed,
223
+ "success_rate": (self.total_successful / self.total_requests * 100) if self.total_requests > 0 else 0,
224
+ "requests_per_second": self.total_requests / uptime if uptime > 0 else 0,
225
+ "total_bytes_in": self.total_bytes_in,
226
+ "total_bytes_out": self.total_bytes_out,
227
+ "bandwidth_savings_percent": ((self.total_bytes_in - self.total_bytes_out) / self.total_bytes_in * 100) if self.total_bytes_in > 0 else 0
228
+ }
229
+
230
+ async def emit_to_partykit(self, event_type: str, data: Dict[str, Any]):
231
+ """Emit event to PartyKit room."""
232
+ if not self.partykit_url:
233
+ return
234
+
235
+ try:
236
+ client = await self._get_client()
237
+
238
+ event = {
239
+ "type": event_type,
240
+ "instance_id": self.instance_id,
241
+ "timestamp": datetime.utcnow().isoformat(),
242
+ "data": data
243
+ }
244
+
245
+ # POST to PartyKit HTTP endpoint
246
+ await client.post(
247
+ f"{self.partykit_url}/parties/main/stress-test",
248
+ json=event
249
+ )
250
+
251
+ except Exception as e:
252
+ logger.debug(f"PartyKit emit failed: {e}")
253
+
254
+ def to_prometheus(self) -> str:
255
+ """Export metrics in Prometheus format."""
256
+ stats = self.get_lifetime_stats()
257
+ window = self.get_window_metrics(60)
258
+
259
+ lines = [
260
+ f'# HELP emergent_requests_total Total number of translation requests',
261
+ f'# TYPE emergent_requests_total counter',
262
+ f'emergent_requests_total{{instance="{self.instance_id}"}} {stats["total_requests"]}',
263
+ f'',
264
+ f'# HELP emergent_requests_successful_total Successful requests',
265
+ f'# TYPE emergent_requests_successful_total counter',
266
+ f'emergent_requests_successful_total{{instance="{self.instance_id}"}} {stats["total_successful"]}',
267
+ f'',
268
+ f'# HELP emergent_requests_failed_total Failed requests',
269
+ f'# TYPE emergent_requests_failed_total counter',
270
+ f'emergent_requests_failed_total{{instance="{self.instance_id}"}} {stats["total_failed"]}',
271
+ f'',
272
+ f'# HELP emergent_latency_ms Request latency in milliseconds',
273
+ f'# TYPE emergent_latency_ms summary',
274
+ f'emergent_latency_ms{{instance="{self.instance_id}",quantile="0.5"}} {window.latency_p50_ms}',
275
+ f'emergent_latency_ms{{instance="{self.instance_id}",quantile="0.95"}} {window.latency_p95_ms}',
276
+ f'emergent_latency_ms{{instance="{self.instance_id}",quantile="0.99"}} {window.latency_p99_ms}',
277
+ f'',
278
+ f'# HELP emergent_compression_ratio Average compression ratio',
279
+ f'# TYPE emergent_compression_ratio gauge',
280
+ f'emergent_compression_ratio{{instance="{self.instance_id}"}} {window.avg_compression_ratio}',
281
+ f'',
282
+ f'# HELP emergent_rps Requests per second (60s window)',
283
+ f'# TYPE emergent_rps gauge',
284
+ f'emergent_rps{{instance="{self.instance_id}"}} {window.requests_per_second}',
285
+ f'',
286
+ f'# HELP emergent_bytes_in_total Total bytes received',
287
+ f'# TYPE emergent_bytes_in_total counter',
288
+ f'emergent_bytes_in_total{{instance="{self.instance_id}"}} {stats["total_bytes_in"]}',
289
+ f'',
290
+ f'# HELP emergent_bytes_out_total Total bytes sent (compressed)',
291
+ f'# TYPE emergent_bytes_out_total counter',
292
+ f'emergent_bytes_out_total{{instance="{self.instance_id}"}} {stats["total_bytes_out"]}',
293
+ ]
294
+
295
+ return '\n'.join(lines)
296
+
297
+
298
+ # Global metrics instance
299
+ _metrics: Optional[MetricsCollector] = None
300
+
301
+
302
+ def get_metrics() -> MetricsCollector:
303
+ """Get or create global metrics collector."""
304
+ global _metrics
305
+ if _metrics is None:
306
+ _metrics = MetricsCollector(
307
+ instance_id=os.getenv("FLY_ALLOC_ID", "local")[:8]
308
+ )
309
+ return _metrics
310
+
311
+
312
+ async def emit_event(event_type: str, data: Dict[str, Any]):
313
+ """Emit event to PartyKit (convenience function)."""
314
+ metrics = get_metrics()
315
+ await metrics.emit_to_partykit(event_type, data)
316
+
317
+
318
+ # Structured logging setup
319
+
320
+ class JSONFormatter(logging.Formatter):
321
+ """JSON log formatter for structured logging."""
322
+
323
+ def format(self, record: logging.LogRecord) -> str:
324
+ log_data = {
325
+ "timestamp": datetime.utcnow().isoformat(),
326
+ "level": record.levelname,
327
+ "logger": record.name,
328
+ "message": record.getMessage(),
329
+ "instance": os.getenv("FLY_ALLOC_ID", "local")[:8],
330
+ "region": os.getenv("FLY_REGION", "local"),
331
+ }
332
+
333
+ # Add extra fields
334
+ if hasattr(record, "request_id"):
335
+ log_data["request_id"] = record.request_id
336
+ if hasattr(record, "latency_ms"):
337
+ log_data["latency_ms"] = record.latency_ms
338
+ if hasattr(record, "compression_ratio"):
339
+ log_data["compression_ratio"] = record.compression_ratio
340
+ if hasattr(record, "client_ip"):
341
+ log_data["client_ip"] = record.client_ip
342
+
343
+ # Add exception info
344
+ if record.exc_info:
345
+ log_data["exception"] = self.formatException(record.exc_info)
346
+
347
+ return json.dumps(log_data)
348
+
349
+
350
+ def setup_structured_logging(level: str = "INFO"):
351
+ """Configure structured JSON logging."""
352
+ root_logger = logging.getLogger()
353
+ root_logger.setLevel(getattr(logging, level.upper()))
354
+
355
+ # Remove existing handlers
356
+ for handler in root_logger.handlers[:]:
357
+ root_logger.removeHandler(handler)
358
+
359
+ # Add JSON handler
360
+ handler = logging.StreamHandler()
361
+ handler.setFormatter(JSONFormatter())
362
+ root_logger.addHandler(handler)
363
+
364
+ logger.info("Structured logging configured", extra={"level": level})
365
+
366
+
367
+ # Stress test event types
368
+
369
+ class StressTestEvents:
370
+ """Event types for stress test monitoring."""
371
+
372
+ TEST_STARTED = "stress_test.started"
373
+ TEST_PROGRESS = "stress_test.progress"
374
+ TEST_COMPLETED = "stress_test.completed"
375
+
376
+ INSTANCE_JOINED = "instance.joined"
377
+ INSTANCE_HEARTBEAT = "instance.heartbeat"
378
+ INSTANCE_LEFT = "instance.left"
379
+
380
+ METRICS_UPDATE = "metrics.update"
381
+ ERROR_OCCURRED = "error.occurred"
382
+
383
+ SCALING_EVENT = "scaling.event"
384
+
385
+
386
+ async def emit_test_started(
387
+ instance_id: str,
388
+ target_url: str,
389
+ num_clients: int,
390
+ requests_per_client: int
391
+ ):
392
+ """Emit stress test started event."""
393
+ await emit_event(StressTestEvents.TEST_STARTED, {
394
+ "instance_id": instance_id,
395
+ "target_url": target_url,
396
+ "num_clients": num_clients,
397
+ "requests_per_client": requests_per_client,
398
+ "total_requests": num_clients * requests_per_client
399
+ })
400
+
401
+
402
+ async def emit_test_progress(
403
+ instance_id: str,
404
+ completed: int,
405
+ total: int,
406
+ current_rps: float,
407
+ avg_latency_ms: float
408
+ ):
409
+ """Emit stress test progress event."""
410
+ await emit_event(StressTestEvents.TEST_PROGRESS, {
411
+ "instance_id": instance_id,
412
+ "completed": completed,
413
+ "total": total,
414
+ "percent": (completed / total * 100) if total > 0 else 0,
415
+ "current_rps": current_rps,
416
+ "avg_latency_ms": avg_latency_ms
417
+ })
418
+
419
+
420
+ async def emit_test_completed(
421
+ instance_id: str,
422
+ report: Dict[str, Any]
423
+ ):
424
+ """Emit stress test completed event."""
425
+ await emit_event(StressTestEvents.TEST_COMPLETED, {
426
+ "instance_id": instance_id,
427
+ "report": report
428
+ })
429
+
430
+
431
+ async def emit_metrics_update(window_seconds: int = 10):
432
+ """Emit current metrics update."""
433
+ metrics = get_metrics()
434
+ window = metrics.get_window_metrics(window_seconds)
435
+
436
+ await emit_event(StressTestEvents.METRICS_UPDATE, asdict(window))
File without changes