@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.
- package/Dockerfile +28 -0
- package/addon/context_classifier.py +375 -0
- package/addon/providers.py +483 -0
- package/addon/reporter.py +778 -0
- package/addon/requirements.txt +1 -0
- package/addon/rulemetric_addon.py +1288 -0
- package/addon/secret_redactor.py +130 -0
- package/addon/security_scanner.py +828 -0
- package/addon/session_linker.py +206 -0
- package/addon/sse_parser.py +364 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/anthropic.d.ts +8 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +135 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +43 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +125 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/gemini.d.ts +9 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +140 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +116 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +129 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/session-linker.d.ts +6 -0
- package/dist/session-linker.d.ts.map +1 -0
- package/dist/session-linker.js +68 -0
- package/dist/session-linker.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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()
|