@rulemetric/proxy 0.2.2

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 (47) hide show
  1. package/Dockerfile +28 -0
  2. package/addon/context_classifier.py +375 -0
  3. package/addon/providers.py +483 -0
  4. package/addon/reporter.py +778 -0
  5. package/addon/requirements.txt +1 -0
  6. package/addon/rulemetric_addon.py +1288 -0
  7. package/addon/secret_redactor.py +130 -0
  8. package/addon/security_scanner.py +828 -0
  9. package/addon/session_linker.py +206 -0
  10. package/addon/sse_parser.py +364 -0
  11. package/dist/index.d.ts +10 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +8 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +135 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/base.d.ts +11 -0
  20. package/dist/providers/base.d.ts.map +1 -0
  21. package/dist/providers/base.js +43 -0
  22. package/dist/providers/base.js.map +1 -0
  23. package/dist/providers/bedrock.d.ts +8 -0
  24. package/dist/providers/bedrock.d.ts.map +1 -0
  25. package/dist/providers/bedrock.js +125 -0
  26. package/dist/providers/bedrock.js.map +1 -0
  27. package/dist/providers/gemini.d.ts +9 -0
  28. package/dist/providers/gemini.d.ts.map +1 -0
  29. package/dist/providers/gemini.js +140 -0
  30. package/dist/providers/gemini.js.map +1 -0
  31. package/dist/providers/index.d.ts +8 -0
  32. package/dist/providers/index.d.ts.map +1 -0
  33. package/dist/providers/index.js +116 -0
  34. package/dist/providers/index.js.map +1 -0
  35. package/dist/providers/openai.d.ts +9 -0
  36. package/dist/providers/openai.d.ts.map +1 -0
  37. package/dist/providers/openai.js +129 -0
  38. package/dist/providers/openai.js.map +1 -0
  39. package/dist/session-linker.d.ts +6 -0
  40. package/dist/session-linker.d.ts.map +1 -0
  41. package/dist/session-linker.js +68 -0
  42. package/dist/session-linker.js.map +1 -0
  43. package/dist/types.d.ts +41 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +33 -0
@@ -0,0 +1,778 @@
1
+ """Non-blocking HTTP reporter for sending events + context snapshots to the
2
+ RuleMetric API, with on-disk spool for durability across API outages and
3
+ proxy restarts.
4
+
5
+ Three layers, in order:
6
+
7
+ 1. **Inline retry** — `_post_with_retry` does 3 attempts with exponential
8
+ backoff (0.5s, 1s) and treats 4xx as a permanent failure (no retry,
9
+ no spool). 5xx and transport errors exhaust retries before falling
10
+ through to the spool.
11
+
12
+ 2. **Spool** — `~/.config/rulemetric/event-spool.jsonl` is an append-only
13
+ JSONL queue. Anything that exhausts retries lands here. Append is
14
+ `O_APPEND`+`fsync` per line so a crash mid-write loses at most that one
15
+ line. The file is FIFO-evicted at 100 MB to bound disk use.
16
+
17
+ 3. **Drainer** — a single background thread (`_drain_loop`) wakes every
18
+ 30 seconds while the spool is non-empty. It re-POSTs each line through
19
+ the same retry helper. On persistent 5xx, it sleeps and retries later.
20
+ On 4xx, it drops the line. Successful drains rewrite the spool with
21
+ the remaining lines (atomic rename).
22
+
23
+ **Important caveat about replay ordering:** the spool stores the request
24
+ body verbatim. The receiver auto-assigns `sequence` on insert, so a
25
+ replayed event gets a NEW sequence number — it appears AFTER the gap in
26
+ the timeline rather than filling it. The total event count is correct;
27
+ the relative ordering is not. Documented in
28
+ `docs/plans/2026-05-02-proxy-reporter-durability-prp.md` as accepted
29
+ trade-off for v1.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ import shutil
38
+ import tempfile
39
+ import threading
40
+ import time
41
+ from typing import Any
42
+ from urllib.error import HTTPError, URLError
43
+ from urllib.request import Request, urlopen
44
+
45
+ from secret_redactor import redact_snapshot
46
+
47
+ logger = logging.getLogger("rulemetric.reporter")
48
+
49
+ DEFAULT_TIMEOUT = 5 # seconds (was 2 — too aggressive for cold starts)
50
+ MAX_RETRIES = 2 # 3 total attempts
51
+ RETRY_BACKOFF = 0.5 # seconds — exponential backoff base
52
+
53
+ SPOOL_MAX_BYTES = 100 * 1024 * 1024 # 100 MB cap before FIFO eviction
54
+ SPOOL_EVICT_FRACTION = 0.10 # evict the oldest 10% when over cap
55
+ DRAIN_INTERVAL_SECONDS = 30 # how often to re-attempt spooled items
56
+
57
+ # ─── Stats ────────────────────────────────────────────────────────────────
58
+ # Per-kind so operators can distinguish "events flowing fine but snapshots
59
+ # failing" from "everything is broken". Kinds: "event", "snapshot".
60
+ _stats_lock = threading.Lock()
61
+ _STAT_KEYS = (
62
+ "posted",
63
+ "failed_permanent", # 4xx — receiver rejected, replay won't help
64
+ "spooled", # exhausted retries, parked on disk
65
+ "replayed_ok", # successfully drained from spool
66
+ "replayed_failed", # 4xx during drain → dropped
67
+ "dropped_evicted", # FIFO-evicted from spool when over cap
68
+ "dropped_corrupt", # unparseable JSON line in spool
69
+ "skipped", # no session_id at call site
70
+ )
71
+ _stats: dict[str, dict[str, int]] = {
72
+ "event": {k: 0 for k in _STAT_KEYS},
73
+ "snapshot": {k: 0 for k in _STAT_KEYS},
74
+ }
75
+
76
+ # Debounced instruction linking — trigger once per session, not every snapshot
77
+ _linked_sessions: set[str] = set()
78
+ _link_lock = threading.Lock()
79
+
80
+ # Spool serialization — protects file r/w across drainer + appenders
81
+ _spool_lock = threading.Lock()
82
+ _drain_thread: threading.Thread | None = None
83
+ _drain_started_lock = threading.Lock()
84
+
85
+
86
+ def _bump(kind: str, key: str, n: int = 1) -> None:
87
+ with _stats_lock:
88
+ _stats[kind][key] += n
89
+
90
+
91
+ def get_stats() -> dict[str, dict[str, int]]:
92
+ """Return a deep copy of the per-kind stats."""
93
+ with _stats_lock:
94
+ return {kind: dict(values) for kind, values in _stats.items()}
95
+
96
+
97
+ def _get_api_url() -> str:
98
+ return os.environ.get("RULEMETRIC_API_URL", "http://localhost:3000")
99
+
100
+
101
+ def _get_auth_headers() -> dict[str, str]:
102
+ api_key = os.environ.get("RULEMETRIC_API_KEY", "")
103
+ token = os.environ.get("RULEMETRIC_ACCESS_TOKEN", "")
104
+
105
+ headers: dict[str, str] = {"Content-Type": "application/json"}
106
+ if api_key:
107
+ headers["Authorization"] = f"Bearer {api_key}"
108
+ elif token:
109
+ headers["Authorization"] = f"Bearer {token}"
110
+ else:
111
+ logger.warning(
112
+ "No auth credentials found — API calls will fail. "
113
+ "Set RULEMETRIC_API_KEY or RULEMETRIC_ACCESS_TOKEN."
114
+ )
115
+ return headers
116
+
117
+
118
+ def _config_dir() -> str:
119
+ """Mirror the CLI / hooks config-dir resolution."""
120
+ override = os.environ.get("RULEMETRIC_CONFIG_DIR")
121
+ if override:
122
+ return override
123
+ home = os.path.expanduser("~")
124
+ return os.path.join(home, ".config", "rulemetric")
125
+
126
+
127
+ def _spool_path() -> str:
128
+ return os.path.join(_config_dir(), "event-spool.jsonl")
129
+
130
+
131
+ def get_active_org_id() -> str | None:
132
+ """Resolve the active org for capture attribution.
133
+
134
+ Priority: ``RULEMETRIC_ORG_ID`` env var beats the local file. The file
135
+ lives at ``$RULEMETRIC_CONFIG_DIR/active-org`` and contains JSON
136
+ written by ``rulemetric org switch``. Missing file, malformed JSON,
137
+ or any I/O error all collapse to ``None`` — the proxy must never crash
138
+ on bad org context.
139
+ """
140
+ env_org = os.environ.get("RULEMETRIC_ORG_ID")
141
+ if env_org and env_org.strip():
142
+ return env_org.strip()
143
+
144
+ path = os.path.join(_config_dir(), "active-org")
145
+ try:
146
+ with open(path, encoding="utf-8") as f:
147
+ payload = json.load(f)
148
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
149
+ return None
150
+ except Exception as exc: # pragma: no cover — defensive
151
+ logger.debug("Unexpected error reading active-org file %s: %s", path, exc)
152
+ return None
153
+
154
+ if not isinstance(payload, dict):
155
+ return None
156
+ org_id = payload.get("orgId")
157
+ if isinstance(org_id, str) and org_id.strip():
158
+ return org_id.strip()
159
+ return None
160
+
161
+
162
+ # ─── Shared retry helper ──────────────────────────────────────────────────
163
+
164
+
165
+ def _post_with_retry(
166
+ url: str,
167
+ data: bytes,
168
+ headers: dict[str, str],
169
+ *,
170
+ log_label: str,
171
+ ) -> tuple[bool, str | None, bool]:
172
+ """POST `data` with up to MAX_RETRIES retries on transport / 5xx errors.
173
+
174
+ Returns:
175
+ (success, last_error_str, is_permanent)
176
+
177
+ `is_permanent=True` means a 4xx from the server: the request will
178
+ never succeed in its current form, so callers SHOULD NOT spool it.
179
+ `is_permanent=False` covers transport errors and 5xx — caller
180
+ should spool the payload for later replay.
181
+ """
182
+ last_error: str | None = None
183
+ is_permanent = False
184
+
185
+ for attempt in range(1 + MAX_RETRIES):
186
+ try:
187
+ req = Request(url, data=data, headers=headers, method="POST")
188
+ with urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
189
+ if resp.status < 300:
190
+ return True, None, False
191
+ # Some receivers may return 3xx — treat as transient.
192
+ last_error = f"HTTP {resp.status}"
193
+ logger.warning(
194
+ "%s: API returned %d on attempt %d/%d",
195
+ log_label, resp.status, attempt + 1, 1 + MAX_RETRIES,
196
+ )
197
+ except HTTPError as exc:
198
+ last_error = f"HTTP {exc.code}: {exc.reason}"
199
+ if 400 <= exc.code < 500:
200
+ # 409 from a duplicate replay is "success" — receiver already
201
+ # has this row. Treat as posted, not failed.
202
+ if exc.code == 409:
203
+ return True, None, False
204
+ logger.error(
205
+ "%s: API rejected request with HTTP %d (%s) — permanent failure",
206
+ log_label, exc.code, exc.reason,
207
+ )
208
+ is_permanent = True
209
+ return False, last_error, True
210
+ logger.warning(
211
+ "%s: HTTP %d on attempt %d/%d: %s",
212
+ log_label, exc.code, attempt + 1, 1 + MAX_RETRIES, exc.reason,
213
+ )
214
+ except (URLError, OSError, TimeoutError) as exc:
215
+ last_error = str(exc)
216
+ logger.warning(
217
+ "%s: transport error on attempt %d/%d: %s",
218
+ log_label, attempt + 1, 1 + MAX_RETRIES, exc,
219
+ )
220
+
221
+ if attempt < MAX_RETRIES:
222
+ time.sleep(RETRY_BACKOFF * (2 ** attempt))
223
+
224
+ return False, last_error, is_permanent
225
+
226
+
227
+ # ─── Spool ────────────────────────────────────────────────────────────────
228
+
229
+
230
+ def _spool_append(kind: str, session_id: str, payload: dict) -> None:
231
+ """Append one envelope line to the spool. Rotates if over cap.
232
+
233
+ Each line: ``{"kind": "...", "session_id": "...", "payload": {...},
234
+ "queued_at": "ISO-8601"}``. Caller owns the kind/session_id; we add
235
+ the timestamp for diagnostics.
236
+ """
237
+ envelope = {
238
+ "kind": kind,
239
+ "session_id": session_id,
240
+ "payload": payload,
241
+ "queued_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
242
+ }
243
+ line = json.dumps(envelope, separators=(",", ":")) + "\n"
244
+ encoded = line.encode("utf-8")
245
+
246
+ path = _spool_path()
247
+ with _spool_lock:
248
+ try:
249
+ os.makedirs(os.path.dirname(path), exist_ok=True)
250
+ # O_APPEND guarantees atomic append on POSIX even with multiple
251
+ # writers. We add fsync so a crash mid-write loses at most this
252
+ # one line, not the lines before it.
253
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
254
+ try:
255
+ os.write(fd, encoded)
256
+ os.fsync(fd)
257
+ finally:
258
+ os.close(fd)
259
+ except OSError as exc:
260
+ logger.error(
261
+ "Failed to append to spool %s: %s. Event lost.", path, exc,
262
+ )
263
+ _bump(kind, "dropped_corrupt")
264
+ return
265
+
266
+ _bump(kind, "spooled")
267
+ _evict_if_over_cap_locked()
268
+
269
+
270
+ def _evict_if_over_cap_locked() -> None:
271
+ """Caller must hold _spool_lock. Evicts oldest ~10% of file if over cap."""
272
+ path = _spool_path()
273
+ try:
274
+ size = os.path.getsize(path)
275
+ except OSError:
276
+ return
277
+ if size <= SPOOL_MAX_BYTES:
278
+ return
279
+
280
+ # Drop enough to land 10% below the cap in a single pass. The old
281
+ # fixed 10%-of-cap drop meant a far-over-cap file (16 GB once) needed
282
+ # hundreds of full-file copies to recover — it never did.
283
+ bytes_to_drop = size - SPOOL_MAX_BYTES + int(
284
+ SPOOL_MAX_BYTES * SPOOL_EVICT_FRACTION
285
+ )
286
+ evicted = 0
287
+ try:
288
+ with open(path, "rb") as src:
289
+ # Skip until we've passed `bytes_to_drop`; align to next newline.
290
+ src.seek(bytes_to_drop)
291
+ src.readline() # consume partial line at boundary
292
+ tmp_fd, tmp_path = tempfile.mkstemp(
293
+ dir=os.path.dirname(path), prefix=".spool-", suffix=".tmp",
294
+ )
295
+ with os.fdopen(tmp_fd, "wb") as dst:
296
+ # Count evicted lines up to the boundary by re-reading.
297
+ src.seek(0)
298
+ pos = 0
299
+ for line in src:
300
+ pos += len(line)
301
+ if pos <= bytes_to_drop:
302
+ evicted += 1
303
+ # Charge eviction by inspecting the kind in each line
304
+ try:
305
+ env = json.loads(line)
306
+ kind = env.get("kind", "event")
307
+ except (json.JSONDecodeError, ValueError):
308
+ kind = "event"
309
+ _bump(kind, "dropped_evicted")
310
+ else:
311
+ dst.write(line)
312
+ # Copy the rest after the eviction boundary
313
+ for line in src:
314
+ dst.write(line)
315
+ os.replace(tmp_path, path)
316
+ logger.warning(
317
+ "Spool over %d bytes — evicted %d oldest entries.",
318
+ SPOOL_MAX_BYTES, evicted,
319
+ )
320
+ except OSError as exc:
321
+ logger.error("Spool eviction failed: %s", exc)
322
+
323
+
324
+ def _replay_envelope(envelope: dict) -> tuple[bool, bool]:
325
+ """Replay one spool envelope. Returns (success, is_permanent).
326
+
327
+ Used by the drainer. On success, the line should be removed from the
328
+ spool. On `is_permanent`, the line should also be removed (dropped).
329
+ On transient failure, the line stays — drainer will sleep and retry.
330
+ """
331
+ kind = envelope.get("kind", "event")
332
+ session_id = envelope.get("session_id")
333
+ payload = envelope.get("payload")
334
+ if not session_id or not isinstance(payload, dict):
335
+ # Corrupt / unexpected shape — drop.
336
+ return False, True
337
+
338
+ api_url = _get_api_url()
339
+ if kind == "event":
340
+ url = f"{api_url}/api/sessions/{session_id}/events"
341
+ elif kind == "snapshot":
342
+ url = f"{api_url}/api/sessions/{session_id}/context-snapshots"
343
+ else:
344
+ # Unknown kind — drop.
345
+ return False, True
346
+
347
+ headers = _get_auth_headers()
348
+ data = json.dumps(payload).encode("utf-8")
349
+ success, _err, is_permanent = _post_with_retry(
350
+ url, data, headers, log_label=f"replay {kind}",
351
+ )
352
+ return success, is_permanent
353
+
354
+
355
+ def _drain_once() -> bool:
356
+ """Snapshot the spool, replay each line, push leftovers back.
357
+
358
+ Returns True if the spool is now empty, False if there's still work
359
+ (transient failure paused the drain or new work arrived).
360
+
361
+ Uses a rename-snapshot to avoid clobbering concurrent appends:
362
+ 1. Lock briefly: rename `event-spool.jsonl` → `event-spool.jsonl.draining`.
363
+ New appends after this point land in a fresh `event-spool.jsonl`.
364
+ 2. Unlock. Read the snapshot, replay each line.
365
+ 3. Lock briefly: prepend any leftovers (replay-paused lines) onto
366
+ `event-spool.jsonl`, preserving FIFO order. Delete snapshot.
367
+ """
368
+ path = _spool_path()
369
+ snapshot = path + ".draining"
370
+
371
+ with _spool_lock:
372
+ try:
373
+ if not os.path.exists(path) or os.path.getsize(path) == 0:
374
+ return True
375
+ except OSError:
376
+ return True
377
+ # If a previous drain crashed mid-flight, a stale snapshot may
378
+ # exist. Merge it back so we don't double-process.
379
+ if os.path.exists(snapshot):
380
+ logger.warning("Recovering stale drain snapshot %s", snapshot)
381
+ try:
382
+ # Stream-append current spool onto the snapshot, then rename
383
+ # back. Never load either file into memory — a runaway spool
384
+ # once reached 16 GB and the previous read()-based merge
385
+ # jetsam-killed the proxy at every boot (2026-06-10).
386
+ with open(snapshot, "ab") as dst, open(path, "rb") as cur:
387
+ shutil.copyfileobj(cur, dst)
388
+ os.replace(snapshot, path)
389
+ except OSError as exc:
390
+ logger.error("Stale snapshot recovery failed: %s", exc)
391
+ return False
392
+ _evict_if_over_cap_locked()
393
+ try:
394
+ os.rename(path, snapshot)
395
+ except OSError as exc:
396
+ logger.error("Spool snapshot rename failed: %s", exc)
397
+ return True
398
+
399
+ # Stream the snapshot line-by-line — never readlines() the whole file
400
+ # into memory. Once a transient failure pauses the drain, remaining
401
+ # lines spill to an on-disk leftovers file instead of a Python list.
402
+ leftovers_path = snapshot + ".leftovers"
403
+ have_leftovers = False
404
+ try:
405
+ with open(snapshot, "rb") as f, open(leftovers_path, "wb") as lf:
406
+ paused = False
407
+ for raw in f:
408
+ if paused:
409
+ lf.write(raw)
410
+ have_leftovers = True
411
+ continue
412
+ text = raw.decode("utf-8", errors="replace").strip()
413
+ if not text:
414
+ continue
415
+ try:
416
+ envelope = json.loads(text)
417
+ except (json.JSONDecodeError, ValueError):
418
+ _bump("event", "dropped_corrupt")
419
+ logger.warning("Skipping corrupt spool line (length=%d)", len(text))
420
+ continue
421
+
422
+ kind = envelope.get("kind", "event")
423
+ success, is_permanent = _replay_envelope(envelope)
424
+ if success:
425
+ _bump(kind, "replayed_ok")
426
+ elif is_permanent:
427
+ _bump(kind, "replayed_failed")
428
+ logger.error(
429
+ "Drain dropped permanently-failed envelope: kind=%s session=%s",
430
+ kind, envelope.get("session_id"),
431
+ )
432
+ else:
433
+ # Transient failure — keep this line and pause the rest.
434
+ paused = True
435
+ lf.write(raw)
436
+ have_leftovers = True
437
+ except OSError as exc:
438
+ logger.error("Spool snapshot read failed: %s", exc)
439
+ try:
440
+ os.remove(leftovers_path)
441
+ except OSError:
442
+ pass
443
+ return True
444
+
445
+ # Merge leftovers (FIFO) in front of any concurrent appends that arrived
446
+ # in `path` while we were replaying. Brief lock: read concurrent lines,
447
+ # write `leftovers + concurrent`, atomic rename.
448
+ with _spool_lock:
449
+ try:
450
+ have_concurrent = (
451
+ os.path.exists(path) and os.path.getsize(path) > 0
452
+ )
453
+ if have_leftovers or have_concurrent:
454
+ tmp_fd, tmp_path = tempfile.mkstemp(
455
+ dir=os.path.dirname(path),
456
+ prefix=".spool-", suffix=".tmp",
457
+ )
458
+ with os.fdopen(tmp_fd, "wb") as dst:
459
+ if have_leftovers:
460
+ with open(leftovers_path, "rb") as lf:
461
+ shutil.copyfileobj(lf, dst)
462
+ if have_concurrent:
463
+ with open(path, "rb") as f:
464
+ shutil.copyfileobj(f, dst)
465
+ dst.flush()
466
+ os.fsync(dst.fileno())
467
+ os.replace(tmp_path, path)
468
+ else:
469
+ # Drain complete — nothing pending and nothing arrived during.
470
+ try:
471
+ os.remove(path)
472
+ except FileNotFoundError:
473
+ pass
474
+ # Snapshot fully consumed.
475
+ for stale in (snapshot, leftovers_path):
476
+ try:
477
+ os.remove(stale)
478
+ except FileNotFoundError:
479
+ pass
480
+ # The merge path above is NOT cap-checked by _spool_append, and
481
+ # an unbounded merge is exactly how the spool once escaped the
482
+ # 100 MB cap and grew to 16 GB. Enforce the cap here too.
483
+ _evict_if_over_cap_locked()
484
+ except OSError as exc:
485
+ logger.error("Spool rewrite failed: %s", exc)
486
+ return False
487
+
488
+ return not have_leftovers and not have_concurrent
489
+
490
+
491
+ def _drain_loop() -> None:
492
+ """Background loop: drain whenever spool is non-empty."""
493
+ logger.info("Spool drainer started.")
494
+ while True:
495
+ try:
496
+ done = _drain_once()
497
+ except Exception as exc: # pragma: no cover — defensive
498
+ logger.error("Drain loop iteration crashed: %s", exc)
499
+ done = False
500
+ # Sleep a fixed interval whether the spool was empty or paused on
501
+ # transient failure. Cheap (only one stat() per cycle when empty).
502
+ time.sleep(DRAIN_INTERVAL_SECONDS if not done else DRAIN_INTERVAL_SECONDS)
503
+
504
+
505
+ def start_drainer() -> None:
506
+ """Spawn the drain thread once. Idempotent.
507
+
508
+ Called from `RuleMetricAddon.__init__` so the drainer runs for the
509
+ proxy's lifetime. Safe to call from tests; subsequent calls are no-ops.
510
+ """
511
+ global _drain_thread
512
+ with _drain_started_lock:
513
+ if _drain_thread is not None and _drain_thread.is_alive():
514
+ return
515
+ _drain_thread = threading.Thread(
516
+ target=_drain_loop, daemon=True, name="rulemetric-spool-drain",
517
+ )
518
+ _drain_thread.start()
519
+
520
+
521
+ # ─── Post functions (now spool-aware) ─────────────────────────────────────
522
+
523
+
524
+ def _post_event(session_id: str, event: dict) -> None:
525
+ """Synchronous POST for session events — called from a background thread."""
526
+ api_url = _get_api_url()
527
+ url = f"{api_url}/api/sessions/{session_id}/events"
528
+ headers = _get_auth_headers()
529
+ data = json.dumps(event).encode("utf-8")
530
+
531
+ success, _err, is_permanent = _post_with_retry(
532
+ url, data, headers, log_label=f"event session={session_id[:8]}",
533
+ )
534
+ if success:
535
+ _bump("event", "posted")
536
+ return
537
+ if is_permanent:
538
+ _bump("event", "failed_permanent")
539
+ return
540
+ # Transient failure exhausted retries — spool for later replay.
541
+ _spool_append("event", session_id, event)
542
+
543
+
544
+ def _post_snapshot(session_id: str, snapshot: dict) -> None:
545
+ """Synchronous POST — called from a background thread."""
546
+ api_url = _get_api_url()
547
+ url = f"{api_url}/api/sessions/{session_id}/context-snapshots"
548
+ headers = _get_auth_headers()
549
+ data = json.dumps(snapshot).encode("utf-8")
550
+
551
+ success, _err, is_permanent = _post_with_retry(
552
+ url, data, headers, log_label=f"snapshot session={session_id[:8]}",
553
+ )
554
+ if success:
555
+ _bump("snapshot", "posted")
556
+ # Auto-link instructions on first snapshot per session
557
+ with _link_lock:
558
+ if session_id not in _linked_sessions:
559
+ _linked_sessions.add(session_id)
560
+ threading.Thread(
561
+ target=_trigger_link_instructions,
562
+ daemon=True,
563
+ name=f"rulemetric-link-{session_id[:8]}",
564
+ ).start()
565
+ return
566
+ if is_permanent:
567
+ _bump("snapshot", "failed_permanent")
568
+ return
569
+ _spool_append("snapshot", session_id, snapshot)
570
+
571
+
572
+ def _trigger_link_instructions() -> None:
573
+ """Call POST /api/sessions/link-instructions to auto-link captured context."""
574
+ api_url = _get_api_url()
575
+ url = f"{api_url}/api/sessions/link-instructions"
576
+ headers = _get_auth_headers()
577
+
578
+ try:
579
+ req = Request(url, data=b"{}", headers=headers, method="POST")
580
+ with urlopen(req, timeout=10) as resp:
581
+ if resp.status < 300:
582
+ body = json.loads(resp.read())
583
+ linked = body.get("linked", 0)
584
+ if linked > 0:
585
+ logger.info(
586
+ "Auto-linked %d instruction(s) to %d session(s)",
587
+ linked, body.get("sessionsLinked", 0),
588
+ )
589
+ except (HTTPError, URLError, OSError, TimeoutError) as exc:
590
+ logger.debug("Auto-link instructions failed (non-critical): %s", exc)
591
+
592
+
593
+ # ─── Auto-session creation ────────────────────────────────────────────────
594
+
595
+ # Provider → tool mapping for auto-created sessions
596
+ _PROVIDER_TO_TOOL: dict[str, str] = {
597
+ "anthropic": "claude_code",
598
+ "github_copilot_fim": "vscode_copilot",
599
+ "github_copilot_chat": "vscode_copilot",
600
+ }
601
+
602
+ # Cache of auto-created session IDs (tool → session_id)
603
+ _auto_sessions: dict[str, str] = {}
604
+ _auto_session_lock = threading.Lock()
605
+
606
+
607
+ def _git_root() -> str | None:
608
+ """Best-effort git root directory from cwd."""
609
+ import subprocess
610
+ try:
611
+ root = subprocess.check_output(
612
+ ["git", "rev-parse", "--show-toplevel"],
613
+ stderr=subprocess.DEVNULL, timeout=3,
614
+ ).decode().strip()
615
+ return root if root else None
616
+ except Exception:
617
+ return None
618
+
619
+
620
+ def _git_info() -> dict[str, Any]:
621
+ """Best-effort git context from cwd: branch, remote, root commit, worktree."""
622
+ import subprocess
623
+ info: dict[str, Any] = {}
624
+ metadata: dict[str, Any] = {}
625
+
626
+ def _run(args: list[str]) -> str:
627
+ try:
628
+ return subprocess.check_output(
629
+ args, stderr=subprocess.DEVNULL, timeout=3,
630
+ ).decode().strip()
631
+ except Exception:
632
+ return ""
633
+
634
+ branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
635
+ if branch:
636
+ info["gitBranch"] = branch
637
+
638
+ remote = _run(["git", "config", "--get", "remote.origin.url"])
639
+ if remote:
640
+ metadata["gitRemote"] = remote
641
+
642
+ # Root commit: SHA of the very first commit. Stability anchor for
643
+ # project identity — survives URL renames. Pick the last line in case
644
+ # the repo has multiple disconnected histories (rare).
645
+ root_commit_output = _run(["git", "rev-list", "--max-parents=0", "HEAD"])
646
+ if root_commit_output:
647
+ root_commit = root_commit_output.split("\n")[-1].strip()
648
+ if root_commit:
649
+ metadata["gitRootCommit"] = root_commit
650
+
651
+ # Worktree detection: in a worktree, --git-dir differs from --git-common-dir.
652
+ git_dir = _run(["git", "rev-parse", "--git-dir"])
653
+ common_dir = _run(["git", "rev-parse", "--git-common-dir"])
654
+ if git_dir and common_dir:
655
+ metadata["isWorktree"] = git_dir != common_dir
656
+
657
+ if metadata:
658
+ info["metadata"] = metadata
659
+ return info
660
+
661
+
662
+ def create_session_for_provider(
663
+ provider: str, project_path: str | None = None,
664
+ ) -> str | None:
665
+ """Create a session via the API for traffic that has no hook-based session.
666
+
667
+ Returns the new session ID, or None on failure. Results are cached so
668
+ only one session is created per tool per proxy lifetime. Now uses the
669
+ shared retry helper so cold-start API blips don't lose every event for
670
+ that tool.
671
+ """
672
+ tool = _PROVIDER_TO_TOOL.get(provider)
673
+ if not tool:
674
+ return None
675
+
676
+ with _auto_session_lock:
677
+ if tool in _auto_sessions:
678
+ return _auto_sessions[tool]
679
+
680
+ api_url = _get_api_url()
681
+ url = f"{api_url}/api/sessions"
682
+ headers = _get_auth_headers()
683
+ payload: dict[str, Any] = {"tool": tool}
684
+ raw_path = project_path or os.getcwd()
685
+ git_root = _git_root()
686
+ payload["projectPath"] = (
687
+ git_root if git_root and raw_path.startswith(git_root) else raw_path
688
+ )
689
+ payload.update(_git_info())
690
+ org_id = get_active_org_id()
691
+ if org_id:
692
+ payload["orgId"] = org_id
693
+ data = json.dumps(payload).encode("utf-8")
694
+
695
+ # Inline retry, no spool, and EXACTLY ONE POST per attempt: session-create
696
+ # is not idempotent for auto-created sessions (no externalSessionId), so
697
+ # spooling or a second confirmation POST would produce duplicate rows.
698
+ # (A previous version POSTed via _post_with_retry and then POSTed AGAIN
699
+ # to read the body — every successful auto-create made two sessions.)
700
+ last_exc: Exception | None = None
701
+ for attempt in range(1 + MAX_RETRIES):
702
+ try:
703
+ req = Request(url, data=data, headers=headers, method="POST")
704
+ with urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
705
+ if resp.status < 300:
706
+ body = json.loads(resp.read())
707
+ session_id = body.get("id")
708
+ if session_id:
709
+ with _auto_session_lock:
710
+ _auto_sessions[tool] = session_id
711
+ logger.info("Auto-created %s session: %s", tool, session_id)
712
+ return session_id
713
+ return None # 2xx without id — retrying would duplicate
714
+ last_exc = RuntimeError(f"HTTP {resp.status}")
715
+ except HTTPError as exc:
716
+ if 400 <= exc.code < 500:
717
+ logger.warning("Auto-create session for %s rejected: HTTP %d", tool, exc.code)
718
+ return None # permanent — retrying won't help
719
+ last_exc = exc
720
+ except (URLError, OSError, TimeoutError, ValueError) as exc:
721
+ last_exc = exc
722
+ if attempt < MAX_RETRIES:
723
+ time.sleep(RETRY_BACKOFF * (2 ** attempt))
724
+
725
+ logger.warning("Failed to auto-create session for %s: %s", tool, last_exc)
726
+ return None
727
+
728
+
729
+ # ─── Public reporting API ─────────────────────────────────────────────────
730
+
731
+
732
+ def report_event(session_id: str | None, event: dict) -> None:
733
+ """Fire-and-forget: POST a session event in a background thread."""
734
+ if not session_id:
735
+ _bump("event", "skipped")
736
+ return
737
+
738
+ threading.Thread(
739
+ target=_post_event,
740
+ args=(session_id, event),
741
+ daemon=True,
742
+ name=f"rulemetric-event-{session_id[:8]}",
743
+ ).start()
744
+
745
+
746
+ def report_snapshot(session_id: str | None, snapshot: dict) -> None:
747
+ """Fire-and-forget: POST snapshot to API in a background thread.
748
+
749
+ Redacts detected secrets from snapshot content before sending.
750
+ """
751
+ if not session_id:
752
+ logger.warning(
753
+ "No session ID — snapshot dropped (provider=%s, model=%s). "
754
+ "Ensure session tracking is active.",
755
+ snapshot.get("provider", "unknown"),
756
+ snapshot.get("model", "unknown"),
757
+ )
758
+ _bump("snapshot", "skipped")
759
+ return
760
+
761
+ try:
762
+ snapshot, findings = redact_snapshot(snapshot)
763
+ if findings:
764
+ logger.info(
765
+ "Redacted %d secret(s) from snapshot (patterns: %s)",
766
+ len(findings),
767
+ ", ".join(f["pattern"] for f in findings[:5]),
768
+ )
769
+ except Exception as exc:
770
+ # Fail-open: redaction failure must not block snapshot reporting
771
+ logger.warning("Secret redaction failed (continuing without): %s", exc)
772
+
773
+ threading.Thread(
774
+ target=_post_snapshot,
775
+ args=(session_id, snapshot),
776
+ daemon=True,
777
+ name=f"rulemetric-report-{session_id[:8]}",
778
+ ).start()