lpedro commited on
Commit
3200602
·
1 Parent(s): 8b92e8e

main - Replace log panel with 5-min health stats and anomaly alerts

Browse files

Raw log tailing on the monitoring page duplicated HF's own log viewer and
wrote every log line to disk per worker for no real benefit. Removed that
entirely (_FileLogHandler, _trim_log_files, get_log_lines, /logs) and
replaced it with per-worker in-process instrumentation: a rolling 5-minute
window of per-image latency/success and cache evictions, aggregated into
throughput/error-rate/latency health stats and four anomaly alerts (worker
not reporting, RSS spike past the cache warm-up grace period, cache
thrashing, error-rate spike). All aggregation stays local per worker and
piggybacks on the existing 5s stats-reporter tick, so it adds negligible
overhead. Also dropped the dead `Model` import and unused `os` import in
main.py.

Files changed (2) hide show
  1. main.py +37 -51
  2. similarity.py +140 -64
main.py CHANGED
@@ -1,4 +1,3 @@
1
- import os
2
  from contextlib import asynccontextmanager
3
  from typing import List
4
 
@@ -7,7 +6,7 @@ from fastapi.responses import HTMLResponse
7
 
8
  from models import RequestModel, ResponseModel
9
  from similarity import (
10
- check_similarity, get_all_memory_stats, get_machine_stats, get_log_lines,
11
  start_memory_stats_reporter, start_memory_trimmer,
12
  )
13
 
@@ -54,19 +53,19 @@ h1{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-style:italic;
54
  .req{font-size:.75rem;color:#94a3b8;font-family:'SF Mono','Fira Code',monospace}
55
  .track{height:5px;background:#1e2942;border-radius:99px;margin-top:.45rem;overflow:hidden}
56
  .fill{height:100%;border-radius:99px;opacity:.7}
57
- .log-section{margin-top:.875rem;border-top:1px solid #1a1a2e;padding-top:.75rem;
58
  display:flex;flex-direction:column;flex:1;min-height:0}
59
- .log-label{font-size:.65rem;color:#475569;font-family:'SF Mono','Fira Code',monospace;
60
  text-transform:uppercase;letter-spacing:.08em;margin-bottom:.4rem;
61
  display:flex;align-items:center;gap:.5rem;flex-shrink:0}
62
- .log-dot{width:6px;height:6px;border-radius:50%;background:#4ade80;
63
- box-shadow:0 0 6px #4ade80;animation:pulse 2s infinite}
64
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
65
- .log-box{background:#060b14;border:1px solid #1e2942;border-radius:6px;
66
- flex:1;min-height:420px;overflow-y:auto;padding:.65rem .875rem;
67
- font-family:ui-monospace,'SF Mono',Menlo,Consolas,'Fira Code',monospace;
68
- font-size:.78rem;line-height:1.85;-webkit-font-smoothing:antialiased}
69
- .log-line{white-space:pre-wrap;word-break:break-all;color:#f4f4f2}
70
  </style>
71
  </head><body>
72
  <header>
@@ -74,23 +73,41 @@ h1{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-style:italic;
74
  <span id="meta" class="meta"></span>
75
  </header>
76
  <div id="grid" class="grid"></div>
77
- <div class="log-section">
78
- <div class="log-label"><span class="log-dot"></span>Application logs</div>
79
- <div id="logs" class="log-box"></div>
80
  </div>
81
  <script>
82
  var MAX=__CACHE_MAX__;
83
  function col(pct){return pct<50?'#4ade80':pct<80?'#fbbf24':'#f87171'}
84
  function workerCol(mb){return mb<800?'#4ade80':mb<1000?'#fbbf24':'#f87171'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  function render(d){
86
- var w=d.workers,m=d.machine;
87
  var gb=(w.reduce(function(s,x){return s+x.rss_mb},0)/1024).toFixed(1);
88
  var rc=col(m.ram_percent),cc=col(m.cpu_percent);
 
89
  document.getElementById('meta').innerHTML=
90
  '<b>'+w.length+'</b> workers &nbsp;&middot;&nbsp; '+
91
  '<b>'+gb+' GB</b> RSS &nbsp;&middot;&nbsp; '+
92
  'RAM <b style="color:'+rc+'">'+m.ram_used_gb+'/'+m.ram_total_gb+' GB</b> &nbsp;&middot;&nbsp; '+
93
  'CPU <b style="color:'+cc+'">'+m.cpu_percent+'%</b> &nbsp;&middot;&nbsp; '+
 
 
 
94
  new Date().toTimeString().slice(0,8);
95
  var maxRss=Math.max.apply(null,w.map(function(x){return x.rss_mb}).concat([1]));
96
  document.getElementById('grid').innerHTML=w.map(function(x){
@@ -102,59 +119,28 @@ function render(d){
102
  '<div class="track"><div class="fill" style="width:'+p+'%;background:'+c+'"></div></div>'+
103
  '</div>';
104
  }).join('');
 
105
  }
106
  function refresh(){
107
  var xhr=new XMLHttpRequest();
108
  xhr.open('GET','/stats');
109
  xhr.onload=function(){
110
  if(xhr.status===200){
111
- try{var d=JSON.parse(xhr.responseText);if(d&&d.workers&&d.machine)render(d);}catch(e){}
112
  }
113
  };
114
  xhr.send();
115
  }
116
  refresh();setInterval(refresh,5000);
117
-
118
- function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
119
- function lineCol(s){
120
- if(/error|exception|traceback|critical/i.test(s)) return '#d03b3b';
121
- if(/warn/i.test(s)) return '#fab219';
122
- return '#f4f4f2';
123
- }
124
- function refreshLogs(){
125
- var xhr=new XMLHttpRequest();
126
- xhr.open('GET','/logs?n=400');
127
- xhr.onload=function(){
128
- if(xhr.status===200){
129
- try{
130
- var d=JSON.parse(xhr.responseText);
131
- if(d&&d.lines&&d.lines.length){
132
- var el=document.getElementById('logs');
133
- var atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<60;
134
- var lines=d.lines.slice().reverse();
135
- el.innerHTML=lines.map(function(l){
136
- return '<div class="log-line" style="color:'+lineCol(l)+'">'+esc(l)+'</div>';
137
- }).join('');
138
- if(atBottom) el.scrollTop=el.scrollHeight;
139
- }
140
- }catch(e){}
141
- }
142
- };
143
- xhr.send();
144
- }
145
- refreshLogs();setInterval(refreshLogs,2000);
146
  </script>
147
  </body></html>""").replace("__CACHE_MAX__", str(_CACHE_MAX))
148
 
149
 
150
  @app.get("/stats", include_in_schema=False)
151
  async def stats_endpoint():
152
- return {"workers": get_all_memory_stats(), "machine": get_machine_stats()}
153
-
154
-
155
- @app.get("/logs", include_in_schema=False)
156
- async def logs_endpoint(n: int = 100):
157
- return {"lines": get_log_lines(n=min(n, 1000))}
158
 
159
 
160
  @app.get("/", include_in_schema=False)
 
 
1
  from contextlib import asynccontextmanager
2
  from typing import List
3
 
 
6
 
7
  from models import RequestModel, ResponseModel
8
  from similarity import (
9
+ check_similarity, get_dashboard_data, get_machine_stats,
10
  start_memory_stats_reporter, start_memory_trimmer,
11
  )
12
 
 
53
  .req{font-size:.75rem;color:#94a3b8;font-family:'SF Mono','Fira Code',monospace}
54
  .track{height:5px;background:#1e2942;border-radius:99px;margin-top:.45rem;overflow:hidden}
55
  .fill{height:100%;border-radius:99px;opacity:.7}
56
+ .alerts-section{margin-top:.875rem;border-top:1px solid #1a1a2e;padding-top:.75rem;
57
  display:flex;flex-direction:column;flex:1;min-height:0}
58
+ .alerts-label{font-size:.65rem;color:#475569;font-family:'SF Mono','Fira Code',monospace;
59
  text-transform:uppercase;letter-spacing:.08em;margin-bottom:.4rem;
60
  display:flex;align-items:center;gap:.5rem;flex-shrink:0}
61
+ .alerts-dot{width:6px;height:6px;border-radius:50%;background:#4ade80;box-shadow:0 0 6px #4ade80}
62
+ .alerts-dot.warn{background:#fab219;box-shadow:0 0 6px #fab219;animation:pulse 2s infinite}
63
  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
64
+ .alerts-box{background:#111827;border:1px solid #1e2942;border-radius:6px;
65
+ flex:1;min-height:180px;overflow-y:auto;padding:.75rem .875rem;
66
+ font-family:'Inter',system-ui,sans-serif;font-size:.82rem;line-height:2}
67
+ .alert-line{display:flex;align-items:baseline;gap:.55rem;color:#f4f4f2}
68
+ .alert-empty{color:#475569;font-family:'SF Mono','Fira Code',monospace;font-size:.75rem}
69
  </style>
70
  </head><body>
71
  <header>
 
73
  <span id="meta" class="meta"></span>
74
  </header>
75
  <div id="grid" class="grid"></div>
76
+ <div class="alerts-section">
77
+ <div class="alerts-label"><span id="alerts-dot" class="alerts-dot"></span>Alertas</div>
78
+ <div id="alerts" class="alerts-box"></div>
79
  </div>
80
  <script>
81
  var MAX=__CACHE_MAX__;
82
  function col(pct){return pct<50?'#4ade80':pct<80?'#fbbf24':'#f87171'}
83
  function workerCol(mb){return mb<800?'#4ade80':mb<1000?'#fbbf24':'#f87171'}
84
+ function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
85
+ function alertColor(s){return /sem reportar|taxa de erro/i.test(s)?'#d03b3b':'#fab219'}
86
+ function renderAlerts(alerts){
87
+ var box=document.getElementById('alerts'),dot=document.getElementById('alerts-dot');
88
+ if(!alerts||!alerts.length){
89
+ box.innerHTML='<div class="alert-empty">sem alertas</div>';
90
+ dot.className='alerts-dot';
91
+ return;
92
+ }
93
+ dot.className='alerts-dot warn';
94
+ box.innerHTML=alerts.map(function(a){
95
+ return '<div class="alert-line"><span style="color:'+alertColor(a)+'">●</span><span>'+esc(a)+'</span></div>';
96
+ }).join('');
97
+ }
98
  function render(d){
99
+ var w=d.workers,m=d.machine,h=d.health;
100
  var gb=(w.reduce(function(s,x){return s+x.rss_mb},0)/1024).toFixed(1);
101
  var rc=col(m.ram_percent),cc=col(m.cpu_percent);
102
+ var ec=h.error_rate_pct>5?'#d03b3b':'#94a3b8';
103
  document.getElementById('meta').innerHTML=
104
  '<b>'+w.length+'</b> workers &nbsp;&middot;&nbsp; '+
105
  '<b>'+gb+' GB</b> RSS &nbsp;&middot;&nbsp; '+
106
  'RAM <b style="color:'+rc+'">'+m.ram_used_gb+'/'+m.ram_total_gb+' GB</b> &nbsp;&middot;&nbsp; '+
107
  'CPU <b style="color:'+cc+'">'+m.cpu_percent+'%</b> &nbsp;&middot;&nbsp; '+
108
+ '<b>'+h.throughput_per_min+'</b> req/min &nbsp;&middot;&nbsp; '+
109
+ '<b style="color:'+ec+'">'+h.error_rate_pct+'%</b> erros &nbsp;&middot;&nbsp; '+
110
+ '<b>'+h.avg_latency_ms+'</b> ms &nbsp;&middot;&nbsp; '+
111
  new Date().toTimeString().slice(0,8);
112
  var maxRss=Math.max.apply(null,w.map(function(x){return x.rss_mb}).concat([1]));
113
  document.getElementById('grid').innerHTML=w.map(function(x){
 
119
  '<div class="track"><div class="fill" style="width:'+p+'%;background:'+c+'"></div></div>'+
120
  '</div>';
121
  }).join('');
122
+ renderAlerts(d.alerts);
123
  }
124
  function refresh(){
125
  var xhr=new XMLHttpRequest();
126
  xhr.open('GET','/stats');
127
  xhr.onload=function(){
128
  if(xhr.status===200){
129
+ try{var d=JSON.parse(xhr.responseText);if(d&&d.workers&&d.machine&&d.health)render(d);}catch(e){}
130
  }
131
  };
132
  xhr.send();
133
  }
134
  refresh();setInterval(refresh,5000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </script>
136
  </body></html>""").replace("__CACHE_MAX__", str(_CACHE_MAX))
137
 
138
 
139
  @app.get("/stats", include_in_schema=False)
140
  async def stats_endpoint():
141
+ data = get_dashboard_data()
142
+ data["machine"] = get_machine_stats()
143
+ return data
 
 
 
144
 
145
 
146
  @app.get("/", include_in_schema=False)
similarity.py CHANGED
@@ -11,12 +11,11 @@ import psutil
11
  import json
12
  import time
13
  import ctypes
14
- from collections import OrderedDict
15
  from concurrent.futures import ThreadPoolExecutor
16
  from typing import List
17
  from tensorflow.keras.applications import MobileNetV2
18
  from tensorflow.keras.applications.mobilenet_v2 import preprocess_input
19
- from tensorflow.keras.models import Model
20
  from tensorflow.keras.preprocessing.image import img_to_array
21
  from sklearn.metrics.pairwise import cosine_similarity
22
  from skimage.metrics import structural_similarity as ssim
@@ -30,58 +29,6 @@ register_heif_opener()
30
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
31
  logging.getLogger("urllib3.connection").setLevel(logging.ERROR)
32
 
33
- _LOG_DIR = "/tmp/similarity_logs"
34
- os.makedirs(_LOG_DIR, exist_ok=True)
35
-
36
-
37
- class _FileLogHandler(logging.Handler):
38
- def __init__(self):
39
- super().__init__(logging.INFO)
40
- self.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S'))
41
- self._file = os.path.join(_LOG_DIR, f"{os.getpid()}.log")
42
-
43
- def emit(self, record):
44
- try:
45
- with open(self._file, 'a') as f:
46
- f.write(self.format(record) + "\n")
47
- except Exception:
48
- pass
49
-
50
-
51
- logging.getLogger().addHandler(_FileLogHandler())
52
-
53
-
54
- def _trim_log_files(max_lines=1000):
55
- try:
56
- for fname in os.listdir(_LOG_DIR):
57
- fpath = os.path.join(_LOG_DIR, fname)
58
- try:
59
- with open(fpath, 'r') as f:
60
- lines = f.readlines()
61
- if len(lines) > max_lines:
62
- with open(fpath, 'w') as f:
63
- f.writelines(lines[-max_lines:])
64
- except (OSError, IOError):
65
- pass
66
- except OSError:
67
- pass
68
-
69
-
70
- def get_log_lines(n=100):
71
- all_lines = []
72
- try:
73
- for fname in os.listdir(_LOG_DIR):
74
- fpath = os.path.join(_LOG_DIR, fname)
75
- try:
76
- with open(fpath, 'r') as f:
77
- all_lines.extend(f.readlines())
78
- except (OSError, IOError):
79
- continue
80
- except OSError:
81
- return []
82
- all_lines.sort()
83
- return [l.rstrip() for l in all_lines[-n:]][::-1]
84
-
85
  _mobilenet = None
86
  _mobilenet_lock = threading.Lock()
87
 
@@ -93,6 +40,69 @@ _request_count = 0
93
 
94
  _process = psutil.Process()
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  _cpu_cache = {"pct": 0.0, "usage": None, "ts": None}
97
 
98
 
@@ -200,15 +210,22 @@ def get_machine_stats():
200
 
201
 
202
  def get_memory_stats():
203
- rss_mb = _process.memory_info().rss / (1024 * 1024)
204
  with _image_cache_lock:
205
  cache_size = len(_image_cache)
 
 
 
 
206
  stats = {
207
  "pid": os.getpid(),
208
- "rss_mb": round(rss_mb, 1),
209
  "cache_size": cache_size,
210
  "requests": _request_count,
 
 
211
  "updated_at": time.time(),
 
212
  }
213
  with open(os.path.join(_MEM_STATS_DIR, f"{stats['pid']}.json"), "w") as f:
214
  json.dump(stats, f)
@@ -236,21 +253,74 @@ def start_memory_trimmer(interval_seconds=300):
236
  while True:
237
  time.sleep(interval_seconds)
238
  _trim_memory()
239
- _trim_log_files()
240
  threading.Thread(target=_trim_loop, daemon=True).start()
241
 
242
 
243
- def get_all_memory_stats():
244
- get_memory_stats()
245
- all_stats = []
246
  for filename in os.listdir(_MEM_STATS_DIR):
 
247
  try:
248
- with open(os.path.join(_MEM_STATS_DIR, filename)) as f:
249
- all_stats.append(json.load(f))
250
  except (OSError, json.JSONDecodeError):
251
  continue
252
- all_stats.sort(key=lambda s: s["pid"])
253
- return all_stats
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  def get_mobilenet():
256
  global _mobilenet
@@ -369,6 +439,7 @@ def _cache_put(asset_code, image):
369
  with _image_cache_lock:
370
  if len(_image_cache) >= _IMAGE_CACHE_MAX:
371
  _image_cache.popitem(last=False)
 
372
  _image_cache[asset_code] = image
373
 
374
 
@@ -512,6 +583,8 @@ def change_similarity_from_quadrants(main_image, main_source, source_image , sou
512
 
513
  def _process_image(args):
514
  i, original_image, original_image_shape, images, quadrant = args
 
 
515
  try:
516
  image = load_image_url(images[i].source, images[i].assetCode, images[i].contentType)
517
  image = cv2.resize(image, original_image_shape[::-1])
@@ -532,6 +605,9 @@ def _process_image(args):
532
  similarity_score = 0
533
  similarity_orb_score = 0
534
  similarity_mobilenet_score = 0
 
 
 
535
 
536
  return ResponseModel(originId=images[i].originId, sequence=images[i].sequence,
537
  assetCode=images[i].assetCode, similarity=similarity_score, similarityOrb=similarity_orb_score, similarityMobileNet=similarity_mobilenet_score)
 
11
  import json
12
  import time
13
  import ctypes
14
+ from collections import OrderedDict, deque
15
  from concurrent.futures import ThreadPoolExecutor
16
  from typing import List
17
  from tensorflow.keras.applications import MobileNetV2
18
  from tensorflow.keras.applications.mobilenet_v2 import preprocess_input
 
19
  from tensorflow.keras.preprocessing.image import img_to_array
20
  from sklearn.metrics.pairwise import cosine_similarity
21
  from skimage.metrics import structural_similarity as ssim
 
29
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
30
  logging.getLogger("urllib3.connection").setLevel(logging.ERROR)
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  _mobilenet = None
33
  _mobilenet_lock = threading.Lock()
34
 
 
40
 
41
  _process = psutil.Process()
42
 
43
+ # Rolling 5-minute window of per-image-comparison events, cache evictions, and
44
+ # RSS samples, used to compute health stats and anomaly alerts. Bounded by both
45
+ # maxlen (hard cap against a traffic spike) and periodic time-based pruning.
46
+ _events_lock = threading.Lock()
47
+ _request_events = deque(maxlen=5000) # (timestamp, duration_ms, success)
48
+ _eviction_events = deque(maxlen=2000) # timestamp
49
+ _rss_history = deque(maxlen=90) # (timestamp, rss_mb), one per 5s reporter tick
50
+
51
+ _WINDOW_SECONDS = 300
52
+ _RSS_SPIKE_WINDOW_SECONDS = 120
53
+ _RSS_SPIKE_MB = 150
54
+ _RSS_SPIKE_MIN_UPTIME_SECONDS = 600
55
+ _CACHE_THRASH_EVICTIONS_PER_MIN = 10
56
+ _ERROR_RATE_ALERT_PCT = 5
57
+ _STALE_AFTER_SECONDS = 15
58
+ _STALE_FILE_DELETE_AFTER_SECONDS = 300
59
+
60
+
61
+ def _record_eviction():
62
+ with _events_lock:
63
+ _eviction_events.append(time.time())
64
+
65
+
66
+ def _record_request_event(duration_ms, success):
67
+ with _events_lock:
68
+ _request_events.append((time.time(), duration_ms, success))
69
+
70
+
71
+ def _compute_window_metrics():
72
+ cutoff = time.time() - _WINDOW_SECONDS
73
+ with _events_lock:
74
+ while _request_events and _request_events[0][0] < cutoff:
75
+ _request_events.popleft()
76
+ while _eviction_events and _eviction_events[0] < cutoff:
77
+ _eviction_events.popleft()
78
+ total = len(_request_events)
79
+ failed = sum(1 for _, _, ok in _request_events if not ok)
80
+ latency_sum = sum(d for _, d, _ in _request_events)
81
+ evictions = len(_eviction_events)
82
+
83
+ minutes = _WINDOW_SECONDS / 60
84
+ return {
85
+ "throughput_per_min": round(total / minutes, 1),
86
+ "error_rate_pct": round((failed / total * 100) if total else 0.0, 1),
87
+ "avg_latency_ms": round((latency_sum / total) if total else 0.0, 1),
88
+ "evictions_per_min": round(evictions / minutes, 1),
89
+ "request_count_window": total,
90
+ }
91
+
92
+
93
+ def _record_rss_sample(rss_mb):
94
+ now = time.time()
95
+ target_ts = now - _RSS_SPIKE_WINDOW_SECONDS
96
+ with _events_lock:
97
+ _rss_history.append((now, rss_mb))
98
+ rss_2min_ago = None
99
+ for ts, value in _rss_history:
100
+ if ts <= target_ts:
101
+ rss_2min_ago = value
102
+ else:
103
+ break
104
+ return round(rss_mb - rss_2min_ago, 1) if rss_2min_ago is not None else 0.0
105
+
106
  _cpu_cache = {"pct": 0.0, "usage": None, "ts": None}
107
 
108
 
 
210
 
211
 
212
  def get_memory_stats():
213
+ rss_mb = round(_process.memory_info().rss / (1024 * 1024), 1)
214
  with _image_cache_lock:
215
  cache_size = len(_image_cache)
216
+
217
+ rss_delta_2min_mb = _record_rss_sample(rss_mb)
218
+ window = _compute_window_metrics()
219
+
220
  stats = {
221
  "pid": os.getpid(),
222
+ "rss_mb": rss_mb,
223
  "cache_size": cache_size,
224
  "requests": _request_count,
225
+ "uptime_seconds": round(time.time() - _process.create_time(), 1),
226
+ "rss_delta_2min_mb": rss_delta_2min_mb,
227
  "updated_at": time.time(),
228
+ **window,
229
  }
230
  with open(os.path.join(_MEM_STATS_DIR, f"{stats['pid']}.json"), "w") as f:
231
  json.dump(stats, f)
 
253
  while True:
254
  time.sleep(interval_seconds)
255
  _trim_memory()
 
256
  threading.Thread(target=_trim_loop, daemon=True).start()
257
 
258
 
259
+ def _read_all_worker_files():
260
+ entries = []
 
261
  for filename in os.listdir(_MEM_STATS_DIR):
262
+ fpath = os.path.join(_MEM_STATS_DIR, filename)
263
  try:
264
+ with open(fpath) as f:
265
+ entries.append((fpath, json.load(f)))
266
  except (OSError, json.JSONDecodeError):
267
  continue
268
+ return entries
269
+
270
+
271
+ def get_dashboard_data():
272
+ get_memory_stats() # refresh this worker's own file before merging
273
+
274
+ now = time.time()
275
+ alive, alerts = [], []
276
+
277
+ for fpath, stats in _read_all_worker_files():
278
+ age = now - stats.get("updated_at", 0)
279
+
280
+ if age > _STALE_FILE_DELETE_AFTER_SECONDS:
281
+ try:
282
+ os.remove(fpath)
283
+ except OSError:
284
+ pass
285
+ continue
286
+
287
+ if age > _STALE_AFTER_SECONDS:
288
+ alerts.append(f"worker {stats['pid']} sem reportar há {int(age)}s")
289
+ continue
290
+
291
+ alive.append(stats)
292
+
293
+ if (stats.get("uptime_seconds", 0) > _RSS_SPIKE_MIN_UPTIME_SECONDS
294
+ and stats.get("rss_delta_2min_mb", 0) > _RSS_SPIKE_MB):
295
+ alerts.append(f"worker {stats['pid']} RAM subiu {stats['rss_delta_2min_mb']:.0f} MB em 2 min")
296
+
297
+ if stats.get("evictions_per_min", 0) > _CACHE_THRASH_EVICTIONS_PER_MIN:
298
+ alerts.append(f"worker {stats['pid']} cache a saturar (~{stats['evictions_per_min']:.0f} evicções/min)")
299
+
300
+ alive.sort(key=lambda s: s["pid"])
301
+
302
+ total_requests = sum(s.get("request_count_window", 0) for s in alive)
303
+ throughput_per_min = round(sum(s.get("throughput_per_min", 0) for s in alive), 1)
304
+ evictions_per_min = round(sum(s.get("evictions_per_min", 0) for s in alive), 1)
305
+ if total_requests:
306
+ error_rate_pct = round(
307
+ sum(s.get("error_rate_pct", 0) * s.get("request_count_window", 0) for s in alive) / total_requests, 1)
308
+ avg_latency_ms = round(
309
+ sum(s.get("avg_latency_ms", 0) * s.get("request_count_window", 0) for s in alive) / total_requests, 1)
310
+ else:
311
+ error_rate_pct = 0.0
312
+ avg_latency_ms = 0.0
313
+
314
+ if error_rate_pct > _ERROR_RATE_ALERT_PCT:
315
+ alerts.append(f"taxa de erro em {error_rate_pct:.0f}% nos últimos 5 min")
316
+
317
+ health = {
318
+ "throughput_per_min": throughput_per_min,
319
+ "error_rate_pct": error_rate_pct,
320
+ "avg_latency_ms": avg_latency_ms,
321
+ "evictions_per_min": evictions_per_min,
322
+ }
323
+ return {"workers": alive, "health": health, "alerts": alerts}
324
 
325
  def get_mobilenet():
326
  global _mobilenet
 
439
  with _image_cache_lock:
440
  if len(_image_cache) >= _IMAGE_CACHE_MAX:
441
  _image_cache.popitem(last=False)
442
+ _record_eviction()
443
  _image_cache[asset_code] = image
444
 
445
 
 
583
 
584
  def _process_image(args):
585
  i, original_image, original_image_shape, images, quadrant = args
586
+ started_at = time.time()
587
+ success = True
588
  try:
589
  image = load_image_url(images[i].source, images[i].assetCode, images[i].contentType)
590
  image = cv2.resize(image, original_image_shape[::-1])
 
605
  similarity_score = 0
606
  similarity_orb_score = 0
607
  similarity_mobilenet_score = 0
608
+ success = False
609
+
610
+ _record_request_event((time.time() - started_at) * 1000, success)
611
 
612
  return ResponseModel(originId=images[i].originId, sequence=images[i].sequence,
613
  assetCode=images[i].assetCode, similarity=similarity_score, similarityOrb=similarity_orb_score, similarityMobileNet=similarity_mobilenet_score)