fleet-framework 0.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.
Files changed (85) hide show
  1. fleet/__init__.py +1 -0
  2. fleet/cli.py +290 -0
  3. fleet/core/__init__.py +69 -0
  4. fleet/core/automation.py +125 -0
  5. fleet/core/backend.py +736 -0
  6. fleet/core/config.py +38 -0
  7. fleet/core/context.py +102 -0
  8. fleet/core/contract.py +87 -0
  9. fleet/core/country_presets.py +50 -0
  10. fleet/core/events.py +55 -0
  11. fleet/core/logging.py +97 -0
  12. fleet/core/memory_backend.py +492 -0
  13. fleet/core/metrics.py +61 -0
  14. fleet/core/otel.py +97 -0
  15. fleet/core/primitives.py +310 -0
  16. fleet/core/protocol.py +171 -0
  17. fleet/core/proxy.py +166 -0
  18. fleet/core/reconcile.py +75 -0
  19. fleet/core/sqlite_backend.py +1117 -0
  20. fleet/core/store.py +104 -0
  21. fleet/master/__init__.py +3 -0
  22. fleet/master/api.py +324 -0
  23. fleet/master/app.py +105 -0
  24. fleet/master/auth.py +132 -0
  25. fleet/master/broadcaster.py +37 -0
  26. fleet/master/dashboard/__init__.py +4 -0
  27. fleet/master/dashboard/router.py +36 -0
  28. fleet/master/dashboard/static/style.css +97 -0
  29. fleet/master/dashboard/templates/index.html +372 -0
  30. fleet/master/metrics_route.py +141 -0
  31. fleet/master/ratelimit.py +55 -0
  32. fleet/master/ws_router.py +142 -0
  33. fleet/worker/__init__.py +3 -0
  34. fleet/worker/agent.py +173 -0
  35. fleet/worker/reconcile_loop.py +246 -0
  36. fleet/worker/slot_runner.py +256 -0
  37. fleet/worker/ws_client.py +164 -0
  38. fleet_browser/__init__.py +21 -0
  39. fleet_browser/browser.py +277 -0
  40. fleet_browser/cert.py +68 -0
  41. fleet_browser/fingerprint.py +327 -0
  42. fleet_browser/humanizer.py +157 -0
  43. fleet_browser/pool.py +241 -0
  44. fleet_browser/proxy_extension.py +122 -0
  45. fleet_browser/solver.py +51 -0
  46. fleet_browser/stealth.py +80 -0
  47. fleet_cloudflare/__init__.py +22 -0
  48. fleet_cloudflare/bypasser.py +168 -0
  49. fleet_cloudflare/harvest.py +266 -0
  50. fleet_cloudflare/replay.py +82 -0
  51. fleet_cloudflare/solver.py +28 -0
  52. fleet_content/__init__.py +24 -0
  53. fleet_content/automation.py +43 -0
  54. fleet_content/contracts.py +76 -0
  55. fleet_detect/__init__.py +26 -0
  56. fleet_detect/contracts.py +67 -0
  57. fleet_detect/detect.py +126 -0
  58. fleet_framework-0.1.0.dist-info/METADATA +160 -0
  59. fleet_framework-0.1.0.dist-info/RECORD +85 -0
  60. fleet_framework-0.1.0.dist-info/WHEEL +5 -0
  61. fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
  62. fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
  63. fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
  64. fleet_headers/__init__.py +28 -0
  65. fleet_headers/profiles.py +131 -0
  66. fleet_jobs/__init__.py +28 -0
  67. fleet_jobs/automation.py +34 -0
  68. fleet_jobs/contracts.py +143 -0
  69. fleet_marketplace/__init__.py +33 -0
  70. fleet_marketplace/automation.py +32 -0
  71. fleet_marketplace/contracts.py +151 -0
  72. fleet_news/__init__.py +21 -0
  73. fleet_news/automation.py +51 -0
  74. fleet_news/contracts.py +59 -0
  75. fleet_place/__init__.py +33 -0
  76. fleet_place/automation.py +37 -0
  77. fleet_place/contracts.py +156 -0
  78. fleet_provider_dataimpulse/__init__.py +82 -0
  79. fleet_provider_evomi/__init__.py +76 -0
  80. fleet_serp/__init__.py +30 -0
  81. fleet_serp/automation.py +47 -0
  82. fleet_serp/contracts.py +100 -0
  83. fleet_social/__init__.py +34 -0
  84. fleet_social/automation.py +44 -0
  85. fleet_social/contracts.py +172 -0
@@ -0,0 +1,492 @@
1
+ """In-process Backend implementation. Drop-in for tests + single-host dev.
2
+
3
+ No external dependencies. Same semantics as RedisBackend within a single
4
+ event loop. Not safe across processes or threads — use the Redis backend
5
+ for any deployment that has more than one process touching the same state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import secrets
13
+ import time
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass
16
+ from typing import Any, Optional
17
+
18
+ from fleet.core.backend import RawItem
19
+
20
+
21
+ @dataclass
22
+ class _PoolEntry:
23
+ payload: bytes
24
+ tags: dict[str, Any]
25
+ expires_at: Optional[float]
26
+
27
+
28
+ class InMemoryBackend:
29
+ def __init__(self, url: str = "memory://") -> None:
30
+ self._url = url
31
+ self._kv: dict[tuple[str, str], tuple[bytes, Optional[float]]] = {}
32
+ self._pools: dict[str, dict[str, _PoolEntry]] = defaultdict(dict)
33
+ self._claims: dict[tuple[str, str], float] = {}
34
+ self._locks: dict[str, tuple[str, float]] = {}
35
+ self._counters: dict[str, int] = defaultdict(int)
36
+ # Three priority tiers per queue: high, normal, low. Reservation
37
+ # drains them in that order.
38
+ self._queues: dict[tuple[str, str], asyncio.Queue[bytes]] = defaultdict(asyncio.Queue)
39
+ # task_id -> (queue_name, body, deadline). Keyed by (name, tid) elsewhere.
40
+ self._processing: dict[tuple[str, str], tuple[bytes, float]] = {}
41
+ # name -> list[(ready_at, body)] sorted by ready_at
42
+ self._scheduled: dict[str, list[tuple[float, bytes]]] = defaultdict(list)
43
+ self._dead: dict[str, list[bytes]] = defaultdict(list)
44
+ self._streams: dict[str, list[tuple[float, bytes]]] = defaultdict(list)
45
+ self._workers: dict[tuple[str, str], dict[str, Any]] = {}
46
+ self._catalog: dict[str, bytes] = {}
47
+ # list[(ts, frame_dict)] sorted by ts, capped at max_entries on push.
48
+ self._stats_history: dict[tuple[str, str], list[tuple[float, dict[str, Any]]]] = defaultdict(list)
49
+
50
+ async def aclose(self) -> None:
51
+ return None
52
+
53
+ def _expired_kv(self, key: tuple[str, str]) -> bool:
54
+ rec = self._kv.get(key)
55
+ return rec is not None and rec[1] is not None and rec[1] < time.time()
56
+
57
+ async def kv_set(self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]) -> None:
58
+ exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
59
+ self._kv[(ns, key)] = (value, exp)
60
+
61
+ async def kv_setnx(
62
+ self, ns: str, key: str, value: bytes, ttl_seconds: Optional[int]
63
+ ) -> bool:
64
+ k = (ns, key)
65
+ if self._expired_kv(k):
66
+ self._kv.pop(k, None)
67
+ if k in self._kv:
68
+ return False
69
+ exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
70
+ self._kv[k] = (value, exp)
71
+ return True
72
+
73
+ async def kv_get(self, ns: str, key: str) -> Optional[bytes]:
74
+ k = (ns, key)
75
+ if self._expired_kv(k):
76
+ self._kv.pop(k, None)
77
+ return None
78
+ rec = self._kv.get(k)
79
+ return rec[0] if rec else None
80
+
81
+ async def kv_del(self, ns: str, key: str) -> bool:
82
+ return self._kv.pop((ns, key), None) is not None
83
+
84
+ def _prune_pool(self, name: str) -> None:
85
+ now = time.time()
86
+ bucket = self._pools[name]
87
+ to_del = [iid for iid, e in bucket.items() if e.expires_at is not None and e.expires_at < now]
88
+ for iid in to_del:
89
+ bucket.pop(iid, None)
90
+ for (n, iid) in list(self._claims):
91
+ if n == name and self._claims[(n, iid)] < now:
92
+ self._claims.pop((n, iid), None)
93
+
94
+ async def pool_put(
95
+ self,
96
+ name: str,
97
+ item_id: str,
98
+ payload: bytes,
99
+ tags: dict[str, Any],
100
+ ttl_seconds: Optional[int],
101
+ ) -> None:
102
+ exp = time.time() + ttl_seconds if (ttl_seconds and ttl_seconds > 0) else None
103
+ self._pools[name][item_id] = _PoolEntry(payload=payload, tags=dict(tags), expires_at=exp)
104
+ self._prune_pool(name)
105
+
106
+ async def pool_list(self, name: str) -> list[RawItem]:
107
+ self._prune_pool(name)
108
+ now = time.time()
109
+ out: list[RawItem] = []
110
+ for iid, e in self._pools[name].items():
111
+ claim_exp = self._claims.get((name, iid))
112
+ in_use = claim_exp if (claim_exp is not None and claim_exp > now) else None
113
+ out.append(RawItem(
114
+ id=iid,
115
+ payload=e.payload,
116
+ tags=e.tags,
117
+ expires_at=e.expires_at,
118
+ in_use_until=in_use,
119
+ ))
120
+ return out
121
+
122
+ async def pool_remove(self, name: str, item_id: str) -> bool:
123
+ removed = self._pools[name].pop(item_id, None) is not None
124
+ self._claims.pop((name, item_id), None)
125
+ return removed
126
+
127
+ async def pool_release(self, name: str, item_id: str) -> bool:
128
+ return self._claims.pop((name, item_id), None) is not None
129
+
130
+ async def pool_claim_any(
131
+ self,
132
+ name: str,
133
+ where: dict[str, Any],
134
+ hold_seconds: int,
135
+ ) -> Optional[RawItem]:
136
+ self._prune_pool(name)
137
+ now = time.time()
138
+ # newest items first, like RedisBackend
139
+ candidates = sorted(
140
+ self._pools[name].items(),
141
+ key=lambda kv: (kv[1].expires_at or 0),
142
+ reverse=True,
143
+ )
144
+ for iid, entry in candidates:
145
+ claim_exp = self._claims.get((name, iid))
146
+ if claim_exp is not None and claim_exp > now:
147
+ continue
148
+ if not all(entry.tags.get(k) == v for k, v in where.items()):
149
+ continue
150
+ self._claims[(name, iid)] = now + hold_seconds
151
+ return RawItem(
152
+ id=iid,
153
+ payload=entry.payload,
154
+ tags=entry.tags,
155
+ expires_at=entry.expires_at,
156
+ in_use_until=now + hold_seconds,
157
+ )
158
+ return None
159
+
160
+ async def lock_acquire(self, name: str, hold_seconds: int, wait_seconds: float) -> Optional[str]:
161
+ deadline = time.monotonic() + max(0.0, wait_seconds)
162
+ backoff = 0.05
163
+ while True:
164
+ now = time.time()
165
+ current = self._locks.get(name)
166
+ if current is None or current[1] < now:
167
+ token = secrets.token_hex(16)
168
+ self._locks[name] = (token, now + max(1, hold_seconds))
169
+ return token
170
+ if time.monotonic() >= deadline:
171
+ return None
172
+ await asyncio.sleep(min(backoff, deadline - time.monotonic()))
173
+ backoff = min(backoff * 2, 0.5)
174
+
175
+ async def lock_release(self, name: str, token: str) -> bool:
176
+ current = self._locks.get(name)
177
+ if current is None:
178
+ return False
179
+ if current[0] != token:
180
+ return False
181
+ self._locks.pop(name, None)
182
+ return True
183
+
184
+ async def counter_incr(self, name: str, by: int) -> int:
185
+ self._counters[name] += by
186
+ return self._counters[name]
187
+
188
+ async def counter_get(self, name: str) -> int:
189
+ return self._counters.get(name, 0)
190
+
191
+ async def counter_set(self, name: str, value: int) -> None:
192
+ self._counters[name] = value
193
+
194
+ async def counter_del(self, name: str) -> None:
195
+ self._counters.pop(name, None)
196
+
197
+ def _tier(self, priority: int) -> str:
198
+ if priority > 0:
199
+ return "high"
200
+ if priority < 0:
201
+ return "low"
202
+ return "normal"
203
+
204
+ def _tiers_in_order(self) -> list[str]:
205
+ return ["high", "normal", "low"]
206
+
207
+ async def queue_push(self, name: str, body: bytes, *, priority: int = 0) -> None:
208
+ await self._queues[(name, self._tier(priority))].put(body)
209
+
210
+ async def queue_size(self, name: str) -> int:
211
+ return sum(
212
+ self._queues[(name, t)].qsize()
213
+ for t in self._tiers_in_order()
214
+ if (name, t) in self._queues
215
+ )
216
+
217
+ async def queue_peek(self, name: str, n: int) -> list[bytes]:
218
+ if n <= 0:
219
+ return []
220
+ out: list[bytes] = []
221
+ for t in self._tiers_in_order():
222
+ key = (name, t)
223
+ if key not in self._queues:
224
+ continue
225
+ q = self._queues[key]
226
+ for body in list(q._queue): # type: ignore[attr-defined]
227
+ if len(out) >= n:
228
+ return out
229
+ out.append(body)
230
+ return out
231
+
232
+ async def pool_size(self, name: str) -> int:
233
+ return len(self._pools.get(name, {}))
234
+
235
+ def _move_due_scheduled(self, name: str) -> None:
236
+ now = time.time()
237
+ due = [b for (rt, b) in self._scheduled[name] if rt <= now]
238
+ if not due:
239
+ return
240
+ self._scheduled[name] = [(rt, b) for (rt, b) in self._scheduled[name] if rt > now]
241
+ # Re-pushed scheduled tasks go to the normal tier.
242
+ q = self._queues[(name, "normal")]
243
+ for body in due:
244
+ q.put_nowait(body)
245
+
246
+ async def queue_reserve(
247
+ self, name: str, timeout_seconds: float, visibility_seconds: int
248
+ ) -> Optional[bytes]:
249
+ self._move_due_scheduled(name)
250
+ body: Optional[bytes] = None
251
+ # Try each tier non-blockingly, then poll if nothing immediately available.
252
+ deadline = time.monotonic() + max(0.0, timeout_seconds)
253
+ while True:
254
+ for tier in self._tiers_in_order():
255
+ q = self._queues[(name, tier)]
256
+ if not q.empty():
257
+ body = q.get_nowait()
258
+ break
259
+ if body is not None or time.monotonic() >= deadline:
260
+ break
261
+ await asyncio.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
262
+ if body is None:
263
+ return None
264
+ try:
265
+ envelope = json.loads(body.decode("utf-8"))
266
+ tid = str(envelope.get("id", ""))
267
+ except Exception:
268
+ tid = ""
269
+ if tid:
270
+ d = time.time() + max(1, visibility_seconds)
271
+ self._processing[(name, tid)] = (body, d)
272
+ return body
273
+
274
+ async def queue_ack(self, name: str, task_id: str) -> bool:
275
+ return self._processing.pop((name, task_id), None) is not None
276
+
277
+ async def queue_nack(
278
+ self, name: str, task_id: str, body: bytes, *, delay_seconds: float
279
+ ) -> bool:
280
+ had = self._processing.pop((name, task_id), None) is not None
281
+ if delay_seconds > 0:
282
+ self._scheduled[name].append((time.time() + delay_seconds, body))
283
+ self._scheduled[name].sort(key=lambda x: x[0])
284
+ else:
285
+ await self._queues[(name, "normal")].put(body)
286
+ return had
287
+
288
+ async def queue_sweep_expired(self, name: str, now: Optional[float] = None) -> int:
289
+ now = now if now is not None else time.time()
290
+ moved = 0
291
+ for key in list(self._processing.keys()):
292
+ if key[0] != name:
293
+ continue
294
+ body, deadline = self._processing[key]
295
+ if deadline <= now:
296
+ self._processing.pop(key, None)
297
+ await self._queues[(name, "normal")].put(body)
298
+ moved += 1
299
+ return moved
300
+
301
+ async def queue_dead(self, name: str, body: bytes) -> None:
302
+ self._dead[name].append(body)
303
+
304
+ async def dlq_peek(self, name: str, n: int) -> list[bytes]:
305
+ return list(self._dead.get(name, [])[:n])
306
+
307
+ async def dlq_pop(self, name: str, n: int) -> list[bytes]:
308
+ bucket = self._dead.get(name, [])
309
+ taken = bucket[:n]
310
+ self._dead[name] = bucket[n:]
311
+ return taken
312
+
313
+ async def dlq_length(self, name: str) -> int:
314
+ return len(self._dead.get(name, []))
315
+
316
+ async def dlq_clear(self, name: str) -> int:
317
+ n = len(self._dead.get(name, []))
318
+ self._dead[name] = []
319
+ return n
320
+
321
+ async def stream_push(
322
+ self,
323
+ name: str,
324
+ envelope: bytes,
325
+ *,
326
+ score: float,
327
+ max_len: int,
328
+ ttl_seconds: int,
329
+ ) -> None:
330
+ bucket = self._streams[name]
331
+ bucket.append((score, envelope))
332
+ bucket.sort(key=lambda x: x[0])
333
+ if ttl_seconds > 0:
334
+ cutoff = score - ttl_seconds
335
+ self._streams[name] = [(s, m) for (s, m) in bucket if s >= cutoff]
336
+ if max_len > 0 and len(self._streams[name]) > max_len:
337
+ self._streams[name] = self._streams[name][-max_len:]
338
+
339
+ async def stream_pop(self, name: str, n: int) -> list[bytes]:
340
+ bucket = self._streams[name]
341
+ if n <= 0:
342
+ return []
343
+ taken = bucket[:n]
344
+ self._streams[name] = bucket[n:]
345
+ return [m for _s, m in taken]
346
+
347
+ async def stream_peek(self, name: str, n: int) -> list[bytes]:
348
+ return [m for _s, m in self._streams[name][:n]]
349
+
350
+ async def stream_length(self, name: str) -> int:
351
+ return len(self._streams.get(name, []))
352
+
353
+ async def stream_trim(self, name: str, *, max_len: int, ttl_seconds: int) -> None:
354
+ if name not in self._streams:
355
+ return
356
+ bucket = self._streams[name]
357
+ if ttl_seconds > 0:
358
+ cutoff = time.time() - ttl_seconds
359
+ bucket = [(s, m) for (s, m) in bucket if s >= cutoff]
360
+ if max_len > 0 and len(bucket) > max_len:
361
+ bucket = bucket[-max_len:]
362
+ self._streams[name] = bucket
363
+
364
+ async def event_publish(self, topic: str, body: bytes) -> None:
365
+ # No subscribers in-memory. Plugins that use ctx.events.subscribe must
366
+ # use a real broker; the memory backend is for tests + single-process.
367
+ return None
368
+
369
+ async def worker_register(
370
+ self, automation_type: str, worker_id: str, hardware: dict[str, Any]
371
+ ) -> None:
372
+ key = (automation_type, worker_id)
373
+ existing = self._workers.get(key, {})
374
+ existing["hardware"] = dict(hardware)
375
+ existing.setdefault("config", {})
376
+ existing.setdefault("config_gen", 0)
377
+ existing.setdefault("state", "IDLE")
378
+ existing.setdefault("last_seen", 0.0)
379
+ existing.setdefault("last_error", None)
380
+ existing.setdefault("stats", {})
381
+ self._workers[key] = existing
382
+
383
+ async def worker_get_config(
384
+ self, automation_type: str, worker_id: str
385
+ ) -> tuple[dict[str, Any], int]:
386
+ rec = self._workers.get((automation_type, worker_id))
387
+ if rec is None:
388
+ return {}, 0
389
+ return dict(rec["config"]), rec["config_gen"]
390
+
391
+ async def worker_set_config(
392
+ self, automation_type: str, worker_id: str, config: dict[str, Any]
393
+ ) -> tuple[dict[str, Any], int]:
394
+ key = (automation_type, worker_id)
395
+ rec = self._workers.setdefault(key, {
396
+ "hardware": {}, "config": {}, "config_gen": 0,
397
+ "state": "IDLE", "last_seen": 0.0, "last_error": None, "stats": {},
398
+ })
399
+ rec["config"] = dict(config)
400
+ rec["config_gen"] = int(rec.get("config_gen", 0)) + 1
401
+ return rec["config"], rec["config_gen"]
402
+
403
+ async def worker_set_state(
404
+ self,
405
+ automation_type: str,
406
+ worker_id: str,
407
+ state: str,
408
+ last_error: Optional[str],
409
+ last_seen: float,
410
+ ) -> None:
411
+ key = (automation_type, worker_id)
412
+ rec = self._workers.setdefault(key, {
413
+ "hardware": {}, "config": {}, "config_gen": 0,
414
+ "state": "IDLE", "last_seen": 0.0, "last_error": None, "stats": {},
415
+ })
416
+ rec["state"] = state
417
+ rec["last_seen"] = last_seen
418
+ rec["last_error"] = last_error
419
+
420
+ async def worker_set_stats(
421
+ self, automation_type: str, worker_id: str, stats: dict[str, Any]
422
+ ) -> None:
423
+ key = (automation_type, worker_id)
424
+ rec = self._workers.setdefault(key, {
425
+ "hardware": {}, "config": {}, "config_gen": 0,
426
+ "state": "IDLE", "last_seen": 0.0, "last_error": None, "stats": {},
427
+ })
428
+ rec["stats"] = dict(stats)
429
+
430
+ async def worker_stats_push(
431
+ self,
432
+ automation_type: str,
433
+ worker_id: str,
434
+ ts: float,
435
+ frame: dict[str, Any],
436
+ *,
437
+ max_entries: int = 240,
438
+ ) -> None:
439
+ bucket = self._stats_history[(automation_type, worker_id)]
440
+ bucket.append((ts, dict(frame)))
441
+ bucket.sort(key=lambda x: x[0])
442
+ if max_entries > 0 and len(bucket) > max_entries:
443
+ del bucket[: len(bucket) - max_entries]
444
+
445
+ async def worker_stats_history(
446
+ self,
447
+ automation_type: str,
448
+ worker_id: str,
449
+ *,
450
+ since: float = 0,
451
+ limit: int = 240,
452
+ ) -> list[dict[str, Any]]:
453
+ bucket = self._stats_history.get((automation_type, worker_id), [])
454
+ return [dict(f) for (t, f) in bucket if t >= since][:limit]
455
+
456
+ async def worker_get(
457
+ self, automation_type: str, worker_id: str
458
+ ) -> Optional[dict[str, Any]]:
459
+ rec = self._workers.get((automation_type, worker_id))
460
+ if rec is None:
461
+ return None
462
+ return {
463
+ "hardware": dict(rec["hardware"]),
464
+ "config": dict(rec["config"]),
465
+ "config_gen": rec["config_gen"],
466
+ "state": rec["state"],
467
+ "last_seen": rec["last_seen"],
468
+ "last_error": rec["last_error"],
469
+ "stats": dict(rec["stats"]),
470
+ }
471
+
472
+ async def worker_list(
473
+ self, automation_type: Optional[str] = None
474
+ ) -> list[tuple[str, str]]:
475
+ if automation_type is None:
476
+ return list(self._workers.keys())
477
+ return [(at, wid) for (at, wid) in self._workers if at == automation_type]
478
+
479
+ async def worker_remove(self, automation_type: str, worker_id: str) -> bool:
480
+ return self._workers.pop((automation_type, worker_id), None) is not None
481
+
482
+ async def catalog_set(self, automation_type: str, doc: bytes) -> None:
483
+ self._catalog[automation_type] = doc
484
+
485
+ async def catalog_get(self, automation_type: str) -> Optional[bytes]:
486
+ return self._catalog.get(automation_type)
487
+
488
+ async def catalog_all(self) -> dict[str, bytes]:
489
+ return dict(self._catalog)
490
+
491
+
492
+ __all__ = ["InMemoryBackend"]
fleet/core/metrics.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections import defaultdict, deque
5
+ from dataclasses import dataclass, field
6
+ from threading import Lock
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class _Counter:
12
+ n: int = 0
13
+
14
+
15
+ @dataclass
16
+ class _Gauge:
17
+ v: float = 0.0
18
+
19
+
20
+ @dataclass
21
+ class _Histogram:
22
+ samples: deque = field(default_factory=lambda: deque(maxlen=512))
23
+
24
+
25
+ class SlotMetrics:
26
+ # per-slot counters. dumped via WS Stats frames and surfaced on the dashboard.
27
+ def __init__(self) -> None:
28
+ self._lock = Lock()
29
+ self._counters: dict[str, _Counter] = defaultdict(_Counter)
30
+ self._gauges: dict[str, _Gauge] = defaultdict(_Gauge)
31
+ self._hists: dict[str, _Histogram] = defaultdict(_Histogram)
32
+ self._created_at = time.monotonic()
33
+
34
+ def incr(self, name: str, by: int = 1) -> None:
35
+ with self._lock:
36
+ self._counters[name].n += by
37
+
38
+ def set(self, name: str, value: float) -> None:
39
+ with self._lock:
40
+ self._gauges[name].v = value
41
+
42
+ def observe(self, name: str, value: float) -> None:
43
+ with self._lock:
44
+ self._hists[name].samples.append(value)
45
+
46
+ def snapshot(self) -> dict[str, Any]:
47
+ with self._lock:
48
+ counters = {k: v.n for k, v in self._counters.items()}
49
+ gauges = {k: v.v for k, v in self._gauges.items()}
50
+ hists = {}
51
+ for k, h in self._hists.items():
52
+ if not h.samples:
53
+ continue
54
+ s = sorted(h.samples)
55
+ hists[k] = {
56
+ "n": len(s),
57
+ "p50": s[len(s) // 2],
58
+ "p95": s[int(len(s) * 0.95)],
59
+ "p99": s[int(len(s) * 0.99)],
60
+ }
61
+ return {"counters": counters, "gauges": gauges, "histograms": hists}
fleet/core/otel.py ADDED
@@ -0,0 +1,97 @@
1
+ """Optional OpenTelemetry integration.
2
+
3
+ Importing this module never fails: if `opentelemetry-api` is not installed
4
+ or `OTEL_EXPORTER_OTLP_ENDPOINT` (or `FLEET_OTEL_ENABLED`) is not set, the
5
+ tracer falls back to no-op context managers so wrapping a hot path costs
6
+ roughly one Python function call.
7
+
8
+ Activation:
9
+
10
+ pip install fleet-core[otel]
11
+ export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
12
+ export OTEL_SERVICE_NAME=fleet-worker
13
+
14
+ Then spans appear in your collector for `slot.run_continuous`,
15
+ `slot.run_one`, `slot.reserve`, and `api.submit_task`. All spans set
16
+ `fleet.automation_type`, `fleet.worker_id`, `fleet.slot_id`, and (where
17
+ relevant) `fleet.task_id` attributes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ from contextlib import contextmanager, nullcontext
25
+ from typing import Any, Iterator
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _tracer: Any = None
30
+ _disabled = False
31
+
32
+
33
+ def _build_tracer() -> Any:
34
+ global _tracer, _disabled
35
+ if _disabled:
36
+ return None
37
+ if _tracer is not None:
38
+ return _tracer
39
+ if not _otel_requested():
40
+ _disabled = True
41
+ return None
42
+ try:
43
+ from opentelemetry import trace
44
+ except ImportError:
45
+ logger.info("opentelemetry-api not installed; OTel hooks disabled")
46
+ _disabled = True
47
+ return None
48
+ _tracer = trace.get_tracer("fleet-core")
49
+ return _tracer
50
+
51
+
52
+ def _otel_requested() -> bool:
53
+ if os.environ.get("FLEET_OTEL_ENABLED", "").lower() in ("1", "true", "yes"):
54
+ return True
55
+ return any(k.startswith("OTEL_EXPORTER_") for k in os.environ)
56
+
57
+
58
+ @contextmanager
59
+ def span(name: str, **attrs: Any) -> Iterator[Any]:
60
+ """Start an OTel span if OTel is configured, otherwise a no-op.
61
+
62
+ Attributes are passed in as kwargs and are added to the span. Any
63
+ None-valued attributes are skipped (OTel rejects them)."""
64
+ tracer = _build_tracer()
65
+ if tracer is None:
66
+ with nullcontext() as ctx:
67
+ yield ctx
68
+ return
69
+ cleaned = {f"fleet.{k}": v for k, v in attrs.items() if v is not None}
70
+ with tracer.start_as_current_span(name, attributes=cleaned) as s:
71
+ yield s
72
+
73
+
74
+ def record_exception(exc: BaseException) -> None:
75
+ """Attach an exception to the active span if one exists."""
76
+ tracer = _build_tracer()
77
+ if tracer is None:
78
+ return
79
+ try:
80
+ from opentelemetry import trace
81
+ current = trace.get_current_span()
82
+ if current is not None:
83
+ current.record_exception(exc)
84
+ except Exception:
85
+ return
86
+
87
+
88
+ def reset_for_tests() -> None:
89
+ """Test helper. Clears cached tracer so monkeypatched env reloads."""
90
+ global _tracer, _disabled
91
+ _tracer = None
92
+ _disabled = False
93
+
94
+
95
+ def is_active() -> bool:
96
+ """True if OTel is wired and ready to emit spans."""
97
+ return _build_tracer() is not None