avowa-runtime 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1613 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Avowa, Inc.
3
+ """
4
+ Avowa Runtime SDK — emit live AI-inference signals to Avowa as evidence.
5
+
6
+ Drop this into your inference path. Every call records the signal that PROVES
7
+ your AI-trust claims at runtime: which model, which region, whether customer data
8
+ is used for training, guardrail outcomes — the evidence posture tools can't see.
9
+
10
+ Stdlib-only (urllib) so it adds no dependencies to your app. Sends are
11
+ non-blocking by default (a background worker), so it adds ~0ms to inference.
12
+
13
+ from avowa_runtime import AvowaRuntime
14
+ avowa = AvowaRuntime(ingest_key="avk_runtime_...", region="us-east-1")
15
+
16
+ # Option A — auto-instrument the Anthropic client (captures every call):
17
+ avowa.instrument_anthropic(client)
18
+
19
+ # Option B — wrap a block:
20
+ with avowa.track(model="claude-opus-4-8"):
21
+ resp = client.messages.create(...)
22
+
23
+ # Option C — record explicitly:
24
+ avowa.capture(model="claude-opus-4-8", region="us-east-1", train_on_data=False)
25
+ """
26
+ import os
27
+ import re
28
+ import sys
29
+ import json
30
+ import time
31
+ import hmac
32
+ import hashlib
33
+ import queue
34
+ import atexit
35
+ import inspect
36
+ import threading
37
+ import contextvars
38
+ import urllib.request
39
+ from contextlib import contextmanager
40
+ from urllib.parse import urlsplit
41
+
42
+ __version__ = "0.11.0"
43
+ __all__ = ["AvowaRuntime"]
44
+
45
+
46
+ _CARD_RX = re.compile(r"\b(?:\d[ -]*?){13,16}\b")
47
+
48
+ _PII_PATTERNS = (
49
+ re.compile(r"(?<![\w.+-])[\w.+-]{1,64}@[\w-]{1,63}\.[\w.]{2,63}"),
50
+ re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
51
+ _CARD_RX,
52
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
53
+ re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
54
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,4096}\.[A-Za-z0-9_-]{10,4096}\."),
55
+ re.compile(r"\b(?:sk|rk)-[A-Za-z0-9]{20,200}\b"),
56
+ )
57
+
58
+ SCAN_LIMIT = 32 * 1024
59
+
60
+ DEFERRED_SCAN_MAX = 256
61
+
62
+
63
+ def _cap(text, keep="head"):
64
+ if not isinstance(text, str) or len(text) <= SCAN_LIMIT:
65
+ return text
66
+ return text[-SCAN_LIMIT:] if keep == "tail" else text[:SCAN_LIMIT]
67
+
68
+
69
+ _TRUE = ("1", "true", "yes", "on")
70
+
71
+
72
+ def _disabled():
73
+ return os.getenv("AVOWA_DISABLE", "").strip().lower() in _TRUE
74
+
75
+
76
+ def _private_host(host):
77
+ h = (host or "").strip("[]").lower()
78
+ if not h:
79
+ return False
80
+ if h in ("localhost",) or h.endswith(".localhost") or "." not in h and ":" not in h:
81
+ return True
82
+ try:
83
+ import ipaddress
84
+ ip = ipaddress.ip_address(h)
85
+ return ip.is_loopback or ip.is_private or ip.is_link_local
86
+ except ValueError:
87
+ return False
88
+
89
+
90
+ def _checked_base(url):
91
+ if not url:
92
+ return None, "AVOWA_BASE_URL is not set"
93
+ u = str(url).strip()
94
+ try:
95
+ parts = urlsplit(u if "://" in u else "https://" + u)
96
+ except Exception:
97
+ return None, "AVOWA_BASE_URL is not a valid URL"
98
+ if parts.scheme == "https":
99
+ return u.rstrip("/"), None
100
+ if parts.scheme == "http" and (_private_host(parts.hostname)
101
+ or os.getenv("AVOWA_ALLOW_HTTP", "").strip().lower() in _TRUE):
102
+ return u.rstrip("/"), None
103
+ return None, ("AVOWA_BASE_URL uses %s:// to %s — signals carry your ingest key, so only https:// "
104
+ "is accepted (plain http only to localhost or a private network)"
105
+ % (parts.scheme or "?", parts.hostname or "?"))
106
+
107
+ _INJECTION_PATTERNS = (
108
+ re.compile(r"ignore\s+(?:all\s+)?(?:previous|prior|above)\s+instructions?", re.I),
109
+ re.compile(r"disregard\s+(?:all\s+)?(?:previous|prior|your)\s+(?:instructions?|rules?)", re.I),
110
+ re.compile(r"(?:reveal|print|repeat|show)\s+(?:me\s+)?(?:your\s+)?system\s+prompt", re.I),
111
+ re.compile(r"you\s+are\s+now\s+(?:a|an|in)\b", re.I),
112
+ re.compile(r"\bdeveloper\s+mode\b|\bDAN\s+mode\b", re.I),
113
+ re.compile(r"</?(?:system|instructions?)>", re.I),
114
+ re.compile(r"pretend\s+(?:that\s+)?you\s+(?:are|have)\b", re.I),
115
+ )
116
+
117
+
118
+ def _luhn_ok(digits):
119
+ total, alt = 0, False
120
+ for ch in reversed(digits):
121
+ d = ord(ch) - 48
122
+ if alt:
123
+ d *= 2
124
+ if d > 9:
125
+ d -= 9
126
+ total += d
127
+ alt = not alt
128
+ return total % 10 == 0
129
+
130
+
131
+ def _looks_like_card(text):
132
+ for m in _CARD_RX.finditer(text or ""):
133
+ digits = re.sub(r"[^0-9]", "", m.group(0))
134
+ if 13 <= len(digits) <= 16 and _luhn_ok(digits):
135
+ return True
136
+ return False
137
+
138
+
139
+ _DIGIT = re.compile(r"\d")
140
+ _INJECTION_GATES = (
141
+ lambda f: "ignore" in f, lambda f: "disregard" in f, lambda f: "system" in f, lambda f: "now" in f,
142
+ lambda f: "mode" in f, lambda f: "system>" in f or "instruction" in f, lambda f: "pretend" in f,
143
+ )
144
+ _PII_GATES = (
145
+ lambda t, d: "@" in t, lambda t, d: d and "-" in t, lambda t, d: d, lambda t, d: "AKIA" in t,
146
+ lambda t, d: "-----BEGIN" in t, lambda t, d: "eyJ" in t, lambda t, d: "sk-" in t or "rk-" in t,
147
+ )
148
+
149
+
150
+ def _count_matches(text, patterns):
151
+ if not text or not isinstance(text, str):
152
+ return 0
153
+ n = 0
154
+ for rx in patterns:
155
+ if rx is _CARD_RX:
156
+ n += 1 if _looks_like_card(text) else 0
157
+ elif rx.search(text):
158
+ n += 1
159
+ return n
160
+
161
+
162
+ _PII_NAMES = ("email", "us_ssn", "payment_card", "aws_access_key", "private_key", "jwt", "provider_secret_key")
163
+ _INJECTION_NAMES = ("ignore_instructions", "disregard_rules", "reveal_system_prompt", "role_override",
164
+ "developer_mode", "delimiter_injection", "pretend")
165
+
166
+
167
+ def _apply_scan(evt, scan):
168
+ if not scan:
169
+ return
170
+ prompt, output = scan
171
+ if prompt is not None and "injection_suspected" not in evt:
172
+ fam = injection_families(prompt)
173
+ evt["injection_suspected"], evt["injection_families"] = len(fam), fam
174
+ if output is not None and "pii_detected" not in evt:
175
+ fam = pii_families(output)
176
+ evt["pii_detected"], evt["pii_families"] = len(fam), fam
177
+
178
+
179
+ def pii_families(text):
180
+ """Names of the PII/secret pattern families present in `text`. Runs locally; text is never sent."""
181
+ if not text or not isinstance(text, str):
182
+ return []
183
+ d = any(c in text for c in "0123456789") or (not text.isascii() and bool(_DIGIT.search(text)))
184
+ return [name for name, gate, rx in zip(_PII_NAMES, _PII_GATES, _PII_PATTERNS)
185
+ if gate(text, d) and (_looks_like_card(text) if rx is _CARD_RX else rx.search(text))]
186
+
187
+
188
+ def injection_families(text):
189
+ """Names of the prompt-injection pattern families present in `text`. Runs locally; never sent."""
190
+ if not text or not isinstance(text, str):
191
+ return []
192
+ f = text.casefold()
193
+ return [name for name, gate, rx in zip(_INJECTION_NAMES, _INJECTION_GATES, _INJECTION_PATTERNS)
194
+ if gate(f) and rx.search(text)]
195
+
196
+
197
+ def scan_pii(text):
198
+ """Distinct PII/secret pattern families present in `text`. Runs locally; text is never sent."""
199
+ return len(pii_families(text))
200
+
201
+
202
+ def scan_injection(text):
203
+ """Distinct prompt-injection pattern families present in `text`. Runs locally; text is never sent."""
204
+ return len(injection_families(text))
205
+
206
+
207
+ _ENV_NAMES = ("avowa.env", ".env")
208
+ _env_loaded = False
209
+
210
+
211
+ def load_env(path=None, _search_up=3):
212
+ """Fill in missing AVOWA_* variables from the `avowa.env` shipped in your SDK download.
213
+
214
+ The zip arrives with your keys already in it, so requiring people to re-type them into the
215
+ shell is a step that buys nothing — and it is the step where integrations die (`set` instead
216
+ of `export`, a var exported in one terminal and the app started in another, a Compose file
217
+ that never passed it through).
218
+
219
+ Two rules make this safe to do automatically:
220
+ • **Only `AVOWA_*` keys are read.** Applications keep database passwords and API keys in
221
+ the same `.env`; a telemetry SDK has no business parsing them, so it doesn't.
222
+ • **The real environment always wins.** Anything already exported is left alone, so a
223
+ Kubernetes Secret or Secrets Manager injection is never silently overridden by a file
224
+ someone forgot to delete.
225
+
226
+ Set AVOWA_ENV_FILE to point at a specific file, or AVOWA_NO_ENV_FILE=1 to switch this off.
227
+ Returns the path actually loaded, or None.
228
+ """
229
+ if os.getenv("AVOWA_NO_ENV_FILE", "").lower() in ("1", "true", "yes", "on"):
230
+ return None
231
+ candidates = []
232
+ explicit = path or os.getenv("AVOWA_ENV_FILE")
233
+ if explicit:
234
+ candidates.append(explicit)
235
+ else:
236
+ d = os.getcwd()
237
+ for _ in range(max(1, _search_up)):
238
+ candidates.extend(os.path.join(d, n) for n in _ENV_NAMES)
239
+ parent = os.path.dirname(d)
240
+ if parent == d:
241
+ break
242
+ d = parent
243
+ for cand in candidates:
244
+ try:
245
+ if not os.path.isfile(cand):
246
+ continue
247
+ with open(cand) as fh:
248
+ lines = fh.readlines()
249
+ except Exception:
250
+ continue
251
+ found = False
252
+ for line in lines:
253
+ line = line.strip()
254
+ if not line or line.startswith("#"):
255
+ continue
256
+ if line.startswith("export "):
257
+ line = line[7:].lstrip()
258
+ key, sep, val = line.partition("=")
259
+ key = key.strip()
260
+ if not sep or not key.startswith("AVOWA_"):
261
+ continue
262
+ val = val.strip().strip('"').strip("'")
263
+ found = True
264
+ os.environ.setdefault(key, val)
265
+ if found:
266
+ return cand
267
+ return None
268
+
269
+
270
+ def _autoload_env():
271
+ global _env_loaded
272
+ if not _env_loaded:
273
+ _env_loaded = True
274
+ try:
275
+ return load_env()
276
+ except Exception:
277
+ return None
278
+ return None
279
+
280
+
281
+ _warned = set()
282
+
283
+
284
+ def _debug_on():
285
+ return os.getenv("AVOWA_DEBUG", "").lower() in ("1", "true", "yes", "on")
286
+
287
+
288
+ def _dbg(msg):
289
+ if _debug_on():
290
+ try:
291
+ sys.stderr.write("[avowa:debug] " + msg + "\n")
292
+ except Exception:
293
+ pass
294
+
295
+
296
+ def _evt_summary(evt):
297
+ seen = evt.get("_sdk_observed") or []
298
+ return "%s via %s (%sms)%s" % (evt.get("model") or "?", evt.get("endpoint") or "?",
299
+ evt.get("latency_ms"),
300
+ (" · derived " + ",".join(seen)) if seen else "")
301
+
302
+
303
+ def _warn_once(key, msg):
304
+ if key not in _warned:
305
+ _warned.add(key)
306
+ try:
307
+ sys.stderr.write("[avowa] " + msg + "\n")
308
+ except Exception:
309
+ pass
310
+
311
+
312
+ INTERNAL_ERRORS = {}
313
+
314
+
315
+ def _strict():
316
+ return os.environ.get("AVOWA_STRICT", "").strip().lower() in _TRUE
317
+
318
+
319
+ def _internal_error(where, exc, fatal_in_strict=True):
320
+ key = f"{where}:{type(exc).__name__}"
321
+ INTERNAL_ERRORS[key] = INTERNAL_ERRORS.get(key, 0) + 1
322
+ if fatal_in_strict and _strict():
323
+ raise exc
324
+ _warn_once("internal:" + key, f"internal error in {where} ({type(exc).__name__}: {str(exc)[:120]}) — "
325
+ "this signal was skipped; your app is unaffected. Please tell security@avowa.ai.")
326
+
327
+
328
+ class _Obj:
329
+ def __init__(self, **kw):
330
+ self.__dict__.update(kw)
331
+
332
+
333
+ class _StreamWatch:
334
+ def __init__(self, record, kwargs, start, label):
335
+ self._record, self._kwargs, self._start, self._label = record, kwargs, start, label
336
+ self._model, self._stop, self._text, self._size, self._done = None, None, [], 0, False
337
+ self._ttft = None
338
+
339
+ def see(self, c):
340
+ if self._ttft is None:
341
+ self._ttft = int((time.time() - self._start) * 1000)
342
+ try:
343
+ t = getattr(c, "type", None) or ""
344
+ msg = getattr(c, "message", None)
345
+ if t == "message_start" and getattr(msg, "model", None):
346
+ self._model = msg.model
347
+ delta = getattr(c, "delta", None)
348
+ if t == "message_delta" and getattr(delta, "stop_reason", None):
349
+ self._stop = delta.stop_reason
350
+ if t == "content_block_delta" and getattr(delta, "text", None):
351
+ self._keep(delta.text)
352
+ if t.endswith("output_text.delta") and isinstance(delta, str):
353
+ self._keep(delta)
354
+ if t == "response.completed" and getattr(getattr(c, "response", None), "model", None):
355
+ self._model = c.response.model
356
+ if getattr(c, "model", None) and not t:
357
+ self._model = c.model
358
+ choices = getattr(c, "choices", None) or []
359
+ if choices:
360
+ ch = choices[0]
361
+ fr = getattr(ch, "finish_reason", None)
362
+ if fr:
363
+ self._stop = fr
364
+ content = getattr(getattr(ch, "delta", None), "content", None)
365
+ if isinstance(content, str) and content:
366
+ self._keep(content)
367
+ except Exception:
368
+ pass
369
+
370
+ def _keep(self, s):
371
+ if self._size < SCAN_LIMIT:
372
+ self._text.append(s)
373
+ self._size += len(s)
374
+
375
+ def finish(self, err):
376
+ if self._done:
377
+ return
378
+ self._done = True
379
+ text = "".join(self._text)
380
+ if self._label.startswith("anthropic"):
381
+ summary = _Obj(model=self._model, stop_reason=self._stop, content=[_Obj(type="text", text=text)])
382
+ else:
383
+ summary = _Obj(model=self._model, output_text=text,
384
+ choices=[_Obj(finish_reason=self._stop, message=_Obj(content=text, tool_calls=None))])
385
+ try:
386
+ self._record(summary, err, int((time.time() - self._start) * 1000), self._kwargs, ttft_ms=self._ttft)
387
+ except Exception as e:
388
+ _internal_error("stream.finish", e)
389
+
390
+
391
+ def _observe_stream(resp, watch):
392
+ it = getattr(resp, "_iterator", None)
393
+ if it is None:
394
+ return _StreamProxy(resp, watch)
395
+ try:
396
+ if hasattr(it, "__anext__"):
397
+ async def _agen():
398
+ try:
399
+ async for c in it:
400
+ watch.see(c)
401
+ yield c
402
+ except Exception as e:
403
+ watch.finish(e)
404
+ raise
405
+ finally:
406
+ watch.finish(None)
407
+ resp._iterator = _agen()
408
+ else:
409
+ def _gen():
410
+ try:
411
+ for c in it:
412
+ watch.see(c)
413
+ yield c
414
+ except Exception as e:
415
+ watch.finish(e)
416
+ raise
417
+ finally:
418
+ watch.finish(None)
419
+ resp._iterator = _gen()
420
+ close = getattr(resp, "close", None)
421
+ if callable(close):
422
+ def _close(*a, **k):
423
+ watch.finish(None)
424
+ return close(*a, **k)
425
+ resp.close = _close
426
+ return resp
427
+ except Exception:
428
+ return _StreamProxy(resp, watch)
429
+
430
+
431
+ class _StreamProxy:
432
+ def __init__(self, inner, watch):
433
+ self._inner, self._watch, self._it = inner, watch, None
434
+
435
+ def __getattr__(self, name):
436
+ return getattr(self._inner, name)
437
+
438
+ def __enter__(self):
439
+ getattr(self._inner, "__enter__", lambda: None)()
440
+ return self
441
+
442
+ def __exit__(self, *exc):
443
+ self._watch.finish(exc[1] if exc and exc[1] else None)
444
+ return getattr(self._inner, "__exit__", lambda *a: None)(*exc)
445
+
446
+ async def __aenter__(self):
447
+ ae = getattr(self._inner, "__aenter__", None)
448
+ if ae:
449
+ await ae()
450
+ return self
451
+
452
+ async def __aexit__(self, *exc):
453
+ self._watch.finish(exc[1] if exc and exc[1] else None)
454
+ ax = getattr(self._inner, "__aexit__", None)
455
+ return await ax(*exc) if ax else None
456
+
457
+ def close(self):
458
+ self._watch.finish(None)
459
+ c = getattr(self._inner, "close", None)
460
+ if c:
461
+ return c()
462
+
463
+ def __iter__(self):
464
+ return self
465
+
466
+ def __next__(self):
467
+ try:
468
+ if self._it is None:
469
+ self._it = self._inner if hasattr(self._inner, "__next__") else iter(self._inner)
470
+ chunk = next(self._it)
471
+ except StopIteration:
472
+ self._watch.finish(None)
473
+ raise
474
+ except Exception as e:
475
+ self._watch.finish(e)
476
+ raise
477
+ self._watch.see(chunk)
478
+ return chunk
479
+
480
+ def __aiter__(self):
481
+ return self
482
+
483
+ async def __anext__(self):
484
+ try:
485
+ chunk = await self._inner.__anext__()
486
+ except StopAsyncIteration:
487
+ self._watch.finish(None)
488
+ raise
489
+ except Exception as e:
490
+ self._watch.finish(e)
491
+ raise
492
+ self._watch.see(chunk)
493
+ return chunk
494
+
495
+
496
+ def _wrap_call(original, record, label):
497
+ record = _with_call_facts(record, label)
498
+
499
+ def wrapped(*args, **kwargs):
500
+ if kwargs.get("stream"):
501
+ start = time.time()
502
+ try:
503
+ resp = original(*args, **kwargs)
504
+ except Exception as e:
505
+ record(None, e, int((time.time() - start) * 1000), kwargs)
506
+ raise
507
+ if inspect.isawaitable(resp):
508
+ async def _awaited_stream():
509
+ try:
510
+ real = await resp
511
+ except Exception as e:
512
+ record(None, e, int((time.time() - start) * 1000), kwargs)
513
+ raise
514
+ return _observe_stream(real, _StreamWatch(record, kwargs, start, label))
515
+ return _awaited_stream()
516
+ return _observe_stream(resp, _StreamWatch(record, kwargs, start, label))
517
+
518
+ start = time.time()
519
+ try:
520
+ resp = original(*args, **kwargs)
521
+ except Exception as e:
522
+ record(None, e, int((time.time() - start) * 1000), kwargs)
523
+ raise
524
+
525
+ if inspect.isawaitable(resp):
526
+ async def _awaited():
527
+ try:
528
+ real = await resp
529
+ except Exception as e:
530
+ record(None, e, int((time.time() - start) * 1000), kwargs)
531
+ raise
532
+ record(real, None, int((time.time() - start) * 1000), kwargs)
533
+ return real
534
+ return _awaited()
535
+
536
+ record(resp, None, int((time.time() - start) * 1000), kwargs)
537
+ return resp
538
+
539
+ wrapped._avowa_wrapped = True
540
+ return wrapped
541
+
542
+
543
+ _CEILING_KEYS = ("max_tokens", "max_completion_tokens", "max_output_tokens")
544
+
545
+
546
+ _IMAGE_PARTS = ("image", "image_url", "input_image")
547
+ _FILE_PARTS = ("document", "file", "input_file")
548
+ _AUDIO_PARTS = ("input_audio", "audio")
549
+
550
+
551
+ def _part_kind(b):
552
+ t = b.get("type") if isinstance(b, dict) else getattr(b, "type", None)
553
+ if t is None and isinstance(b, dict):
554
+ t = next((k for k in ("image", "document", "audio", "text") if k in b), None)
555
+ return t
556
+
557
+
558
+ def _facts_from_request(kwargs):
559
+ kw = kwargs or {}
560
+ out = {}
561
+ for k in _CEILING_KEYS:
562
+ v = kw.get(k)
563
+ if isinstance(v, int) and not isinstance(v, bool) and v > 0:
564
+ out["token_ceiling"] = v
565
+ break
566
+ msgs = kw.get("messages")
567
+ if msgs is None and isinstance(kw.get("input"), list):
568
+ msgs = kw.get("input")
569
+ roles = {"user": 0, "assistant": 0, "tool": 0}
570
+ kinds = {"image": 0, "file": 0, "audio": 0}
571
+ chars, system = 0, bool(kw.get("system") or kw.get("instructions"))
572
+ if isinstance(msgs, list):
573
+ for m in msgs:
574
+ role = m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
575
+ content = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
576
+ if role in ("system", "developer"):
577
+ system = True
578
+ elif role in roles:
579
+ roles[role] += 1
580
+ if isinstance(content, str):
581
+ chars += len(content)
582
+ elif isinstance(content, list):
583
+ for b in content:
584
+ k = _part_kind(b)
585
+ if k in _IMAGE_PARTS:
586
+ kinds["image"] += 1
587
+ elif k in _FILE_PARTS:
588
+ kinds["file"] += 1
589
+ elif k in _AUDIO_PARTS:
590
+ kinds["audio"] += 1
591
+ elif k == "tool_result":
592
+ roles["tool"] += 1
593
+ txt = b.get("text") if isinstance(b, dict) else getattr(b, "text", None)
594
+ if isinstance(txt, str):
595
+ chars += len(txt)
596
+ out.update({"messages_user": roles["user"], "messages_assistant": roles["assistant"],
597
+ "messages_tool": roles["tool"], "input_images": kinds["image"],
598
+ "input_files": kinds["file"], "input_audio": kinds["audio"], "prompt_chars": chars})
599
+ if msgs is not None or system:
600
+ out["system_prompt_present"] = system
601
+ tc = kw.get("tool_choice")
602
+ if tc is not None:
603
+ if isinstance(tc, str):
604
+ out["tool_choice"] = tc[:20]
605
+ else:
606
+ t = tc.get("type") if isinstance(tc, dict) else getattr(tc, "type", None)
607
+ out["tool_choice"] = str(t or "specific")[:20]
608
+ if kw.get("stream") is not None:
609
+ out["streaming"] = bool(kw.get("stream"))
610
+ rf = kw.get("response_format") or kw.get("output_format")
611
+ if rf is not None:
612
+ t = rf.get("type") if isinstance(rf, dict) else getattr(rf, "type", None)
613
+ out["response_format_type"] = str(t or getattr(rf, "__name__", "schema"))[:30]
614
+ return out, list(out)
615
+
616
+
617
+ def _client_facts(client):
618
+ out = {}
619
+ r = getattr(client, "max_retries", None)
620
+ if isinstance(r, int) and not isinstance(r, bool):
621
+ out["client_max_retries"] = r
622
+ t = getattr(client, "timeout", None)
623
+ t = getattr(t, "read", None) if t is not None and not isinstance(t, (int, float)) else t
624
+ if isinstance(t, (int, float)) and not isinstance(t, bool):
625
+ out["client_timeout_s"] = float(t)
626
+ return out
627
+
628
+
629
+ _USAGE_KEYS = {
630
+ "input_tokens": ("input_tokens", "prompt_tokens", "inputTokens", "prompt_token_count"),
631
+ "output_tokens": ("output_tokens", "completion_tokens", "outputTokens", "candidates_token_count"),
632
+ "cache_read_tokens": ("cache_read_input_tokens", "cacheReadInputTokens", "cached_content_token_count"),
633
+ "cache_write_tokens": ("cache_creation_input_tokens", "cacheWriteInputTokens"),
634
+ }
635
+
636
+
637
+ _CODE = re.compile(r"^[A-Za-z0-9_.:-]{1,60}$")
638
+ _REGION = re.compile(r"^[A-Za-z0-9 _-]{2,30}$")
639
+
640
+
641
+ def _g(obj, *path):
642
+ for k in path:
643
+ if obj is None:
644
+ return None
645
+ try:
646
+ if isinstance(obj, (list, tuple)) and k.isdigit():
647
+ obj = obj[int(k)] if int(k) < len(obj) else None
648
+ else:
649
+ obj = obj.get(k) if isinstance(obj, dict) else getattr(obj, k, None)
650
+ except Exception:
651
+ return None
652
+ return obj
653
+
654
+
655
+ def _num_or_none(v):
656
+ return v if isinstance(v, (int, float)) and not isinstance(v, bool) and v == v else None
657
+
658
+
659
+ def _label(v, n=30):
660
+ return v[:n] if isinstance(v, str) and v and _CODE.match(v[:n]) else None
661
+
662
+
663
+ def _param_facts(kwargs):
664
+ kw = kwargs or {}
665
+ cfg = kw.get("config")
666
+ inf = kw.get("inferenceConfig") if isinstance(kw.get("inferenceConfig"), dict) else {}
667
+ out = {}
668
+ for fact, val in (("temperature", kw["temperature"] if "temperature" in kw else _g(cfg, "temperature") if cfg is not None else inf.get("temperature")),
669
+ ("top_p", kw["top_p"] if "top_p" in kw else _g(cfg, "top_p") if cfg is not None else inf.get("topP")),
670
+ ("seed", kw["seed"] if "seed" in kw else _g(cfg, "seed") if cfg is not None else None),
671
+ ("choices_n", kw["n"] if "n" in kw else _g(cfg, "candidate_count") if cfg is not None else None)):
672
+ v = _num_or_none(val)
673
+ if v is not None:
674
+ out[fact] = v
675
+ stops = kw.get("stop_sequences", kw.get("stop", inf.get("stopSequences")))
676
+ if isinstance(stops, str):
677
+ stops = [stops]
678
+ if isinstance(stops, (list, tuple)):
679
+ out["stop_sequences"] = len(stops)
680
+ for fact, key in (("parallel_tool_calls", "parallel_tool_calls"), ("store_requested", "store"),
681
+ ("logprobs", "logprobs")):
682
+ if isinstance(kw.get(key), bool):
683
+ out[fact] = kw[key]
684
+ tier = _label(kw.get("service_tier"), 20)
685
+ if tier:
686
+ out["service_tier_requested"] = tier
687
+ meta = kw.get("metadata")
688
+ if meta is not None:
689
+ out["metadata_present"] = bool(meta)
690
+ if kw.get("user") or kw.get("safety_identifier") or _g(meta, "user_id"):
691
+ out["end_user_id_present"] = True
692
+ elif "user" in kw or "safety_identifier" in kw or meta is not None:
693
+ out["end_user_id_present"] = False
694
+ effort = _label(kw.get("reasoning_effort") or _g(kw.get("reasoning"), "effort"), 20)
695
+ if effort:
696
+ out["reasoning_effort"] = effort
697
+ th = kw.get("thinking")
698
+ if isinstance(th, bool):
699
+ out["thinking_enabled"] = th
700
+ elif th is not None:
701
+ out["thinking_enabled"] = _g(th, "type") not in (None, "disabled")
702
+ if kw.get("guardrailConfig") is not None:
703
+ out["guardrail_configured"] = True
704
+ ss = _g(cfg, "safety_settings") if cfg is not None else None
705
+ if isinstance(ss, (list, tuple)):
706
+ out["safety_settings"] = len(ss)
707
+ return out
708
+
709
+
710
+ def _filter_categories(results):
711
+ out = []
712
+ for r in (results if isinstance(results, list) else [results]):
713
+ inner = _g(r, "content_filter_results")
714
+ r = inner if inner is not None else r
715
+ if isinstance(r, dict):
716
+ out += [k for k, v in r.items() if isinstance(v, dict) and v.get("filtered") is True and _CODE.match(k)]
717
+ return out
718
+
719
+
720
+ def _response_meta_facts(resp):
721
+ if resp is None:
722
+ return {}
723
+ out = {}
724
+ extra = getattr(resp, "model_extra", None)
725
+ extra = extra if isinstance(extra, dict) else {}
726
+ fp = _label(_g(resp, "system_fingerprint") or extra.get("system_fingerprint"), 60)
727
+ if fp:
728
+ out["system_fingerprint"] = fp
729
+ tier = _label(_g(resp, "service_tier") or _g(resp, "usage", "service_tier") or extra.get("service_tier"), 20)
730
+ if tier:
731
+ out["service_tier"] = tier
732
+ cats, present = [], False
733
+ pf = _g(resp, "prompt_filter_results") or extra.get("prompt_filter_results")
734
+ if pf is not None:
735
+ present = True
736
+ cats += _filter_categories(pf)
737
+ choices = _g(resp, "choices")
738
+ for c in (choices if isinstance(choices, (list, tuple)) else [])[:4]:
739
+ cextra = getattr(c, "model_extra", None)
740
+ cf = _g(c, "content_filter_results") or (cextra.get("content_filter_results") if isinstance(cextra, dict) else None)
741
+ if cf is not None:
742
+ present = True
743
+ cats += _filter_categories(cf)
744
+ block = _g(resp, "prompt_feedback", "block_reason")
745
+ if block is not None:
746
+ present = True
747
+ out["provider_block_reason"] = _label(str(getattr(block, "name", block)), 40) or "blocked"
748
+ if isinstance(resp, dict) and _g(resp, "trace", "guardrail") is not None:
749
+ present = True
750
+ if present:
751
+ out["provider_filtered"] = bool(cats) or "provider_block_reason" in out
752
+ if cats:
753
+ out["provider_filter_categories"] = sorted(set(cats))[:10]
754
+ return out
755
+
756
+
757
+ def _error_facts(e):
758
+ out = {}
759
+ resp = getattr(e, "response", None)
760
+ status = next((v for v in (_num_or_none(getattr(e, "status_code", None)),
761
+ _num_or_none(_g(resp, "status_code")),
762
+ _num_or_none(_g(resp, "ResponseMetadata", "HTTPStatusCode")),
763
+ _num_or_none(getattr(e, "code", None))) if v is not None), None)
764
+ if status is not None and 100 <= status <= 599:
765
+ out["http_status"] = int(status)
766
+ body = getattr(e, "body", None)
767
+ code = None
768
+ if isinstance(body, dict):
769
+ code = _g(body, "error", "type") or (body.get("type") if body.get("type") != "error" else None) \
770
+ or _g(body, "code")
771
+ for cand in (code, getattr(e, "code", None), _g(resp, "Error", "Code"), getattr(e, "status", None)):
772
+ c = _label(cand if isinstance(cand, str) else None, 60)
773
+ if c:
774
+ out["provider_error_code"] = c
775
+ break
776
+ try:
777
+ headers = _g(resp, "headers")
778
+ ra = headers.get("retry-after") if headers is not None else None
779
+ if ra is not None:
780
+ out["retry_after_s"] = float(ra)
781
+ except (TypeError, ValueError, AttributeError):
782
+ pass
783
+ return out
784
+
785
+
786
+ _PROVIDER_LIBS = {"openai": "openai", "anthropic": "anthropic", "bedrock": "boto3", "gemini": "google-genai"}
787
+ _LIB_VERSIONS = {}
788
+
789
+
790
+ def _provider_lib_facts(label):
791
+ lib = _PROVIDER_LIBS.get((label or "").split(".", 1)[0])
792
+ if not lib:
793
+ return {}
794
+ if lib not in _LIB_VERSIONS:
795
+ try:
796
+ from importlib.metadata import version
797
+ _LIB_VERSIONS[lib] = version(lib)[:30]
798
+ except Exception:
799
+ _LIB_VERSIONS[lib] = None
800
+ out = {"provider_lib": lib}
801
+ if _LIB_VERSIONS[lib]:
802
+ out["provider_lib_version"] = _LIB_VERSIONS[lib]
803
+ return out
804
+
805
+
806
+ _REGION_VARS = ("AWS_REGION", "AWS_DEFAULT_REGION", "GOOGLE_CLOUD_REGION", "FUNCTION_REGION", "CLOUD_RUN_REGION",
807
+ "REGION_NAME", "AZURE_REGION", "FLY_REGION", "VERCEL_REGION", "RAILWAY_REPLICA_REGION", "RENDER_REGION")
808
+ _FRAMEWORKS = ("langchain", "langgraph", "llama_index", "crewai", "autogen", "haystack", "semantic_kernel", "dspy")
809
+ _ENV_FACTS = None
810
+
811
+
812
+ def _env_facts():
813
+ global _ENV_FACTS
814
+ if _ENV_FACTS is None:
815
+ import platform
816
+ out = {"python_version": platform.python_version()}
817
+ for var in _REGION_VARS:
818
+ v = os.getenv(var)
819
+ if v and _REGION.match(v):
820
+ out["app_region"], out["app_region_source"] = v, var
821
+ break
822
+ _ENV_FACTS = out
823
+ fw = [f for f in _FRAMEWORKS if f in sys.modules]
824
+ return {**_ENV_FACTS, **({"app_frameworks": fw} if fw else {})}
825
+
826
+
827
+ _RF_DENY = frozenset({
828
+ "content", "contents", "text", "texts", "message", "messages", "input", "inputs", "output", "outputs",
829
+ "output_text", "prompt", "prompts", "system", "instructions", "arguments", "args", "delta", "parts",
830
+ "part", "body", "data", "completion", "completions", "document", "documents", "query", "queries",
831
+ "value", "values", "raw", "html", "markdown", "transcript", "summary", "reasoning_content", "thinking",
832
+ })
833
+ _RF_NAME = re.compile(r"^[a-z][a-z0-9_]{1,40}$")
834
+ _RF_SEG = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]{0,40}|\d{1,2})$")
835
+ _RF_PROVIDERS = ("*", "openai", "anthropic", "bedrock", "gemini")
836
+ _RF_MAX = 20
837
+ _REMOTE = {"v": None, "fields": ()}
838
+
839
+
840
+ def _remote_on():
841
+ return (os.getenv("AVOWA_REMOTE_FIELDS") or "on").strip().lower() not in ("off", "0", "false", "no")
842
+
843
+
844
+ def _rf_ok(f):
845
+ if not isinstance(f, dict):
846
+ return False
847
+ name, prov, src, path = f.get("name"), f.get("provider"), f.get("from"), f.get("path")
848
+ if not (isinstance(name, str) and _RF_NAME.match(name) and prov in _RF_PROVIDERS
849
+ and src in ("request", "response", "error") and isinstance(path, str)):
850
+ return False
851
+ segs = path.split(".")
852
+ return 1 <= len(segs) <= 6 and all(
853
+ _RF_SEG.match(x) and not x.startswith("_") and x.lower() not in _RF_DENY for x in segs)
854
+
855
+
856
+ def _rf_value(v):
857
+ if isinstance(v, bool):
858
+ return v
859
+ if isinstance(v, (int, float)):
860
+ return v if v == v and v not in (float("inf"), float("-inf")) else None
861
+ return _label(v, 40) if isinstance(v, str) else None
862
+
863
+
864
+ def _accept_fields(block, secret):
865
+ try:
866
+ doc, mac = block.get("doc"), block.get("mac")
867
+ if not (isinstance(doc, str) and isinstance(mac, str) and secret):
868
+ return
869
+ want = hmac.new(secret.encode(), b"avowa-fields." + doc.encode(), hashlib.sha256).hexdigest()
870
+ if not hmac.compare_digest(want, mac):
871
+ _warn_once("rf-mac", "ignored a metadata-field list that did not verify under this connection's secret.")
872
+ return
873
+ d = json.loads(doc)
874
+ fields = tuple(f for f in (d.get("fields") or [])[:_RF_MAX] if _rf_ok(f))
875
+ _REMOTE["v"], _REMOTE["fields"] = str(d.get("v") or "")[:32] or None, fields
876
+ _dbg("metadata-field list %s: %d field(s)" % (_REMOTE["v"], len(fields)))
877
+ except Exception as e:
878
+ _internal_error("remote_fields", e)
879
+
880
+
881
+ def remote_fields():
882
+ """The server-defined metadata fields this process is currently reading (empty when off)."""
883
+ return [dict(f) for f in _REMOTE["fields"]] if _remote_on() else []
884
+
885
+
886
+ def _remote_facts(label, resp, kwargs, exc):
887
+ if not _REMOTE["fields"] or not _remote_on():
888
+ return {}
889
+ prov = (label or "").split(".", 1)[0]
890
+ src = {"request": kwargs, "response": resp, "error": exc}
891
+ out = {}
892
+ for f in _REMOTE["fields"]:
893
+ if f["provider"] in ("*", prov) and src.get(f["from"]) is not None:
894
+ v = _rf_value(_g(src[f["from"]], *f["path"].split(".")))
895
+ if v is not None:
896
+ out["rf_" + f["name"]] = v
897
+ return out
898
+
899
+
900
+ _CALL_FACTS = contextvars.ContextVar("avowa_call_facts", default=None)
901
+
902
+
903
+ def _with_call_facts(record, label):
904
+ def rec(resp, err, ms, kwargs, ttft_ms=None):
905
+ facts = {}
906
+ try:
907
+ if isinstance(err, BaseException):
908
+ facts.update(_error_facts(err))
909
+ facts.update(_param_facts(kwargs))
910
+ facts.update(_response_meta_facts(resp))
911
+ facts.update(_provider_lib_facts(label))
912
+ if ttft_ms is not None:
913
+ facts["ttft_ms"] = ttft_ms
914
+ facts.update(_remote_facts(label, resp, kwargs, err if isinstance(err, BaseException) else None))
915
+ except Exception as e:
916
+ _internal_error("call_facts", e)
917
+ if isinstance(err, BaseException):
918
+ err = type(err).__name__
919
+ tok = _CALL_FACTS.set(facts)
920
+ try:
921
+ return record(resp, err, ms, kwargs)
922
+ finally:
923
+ _CALL_FACTS.reset(tok)
924
+ return rec
925
+
926
+
927
+ def _usage_facts(resp):
928
+ u = getattr(resp, "usage", None) or getattr(resp, "usage_metadata", None)
929
+ if u is None and isinstance(resp, dict):
930
+ u = resp.get("usage")
931
+ if u is None:
932
+ return {}
933
+ get = (lambda k: u.get(k)) if isinstance(u, dict) else (lambda k: getattr(u, k, None))
934
+ out = {}
935
+ for fact, names in _USAGE_KEYS.items():
936
+ for n in names:
937
+ v = get(n)
938
+ if isinstance(v, int) and not isinstance(v, bool):
939
+ out[fact] = v
940
+ break
941
+ if "cache_read_tokens" not in out:
942
+ for d in ("prompt_tokens_details", "input_tokens_details"):
943
+ det = get(d)
944
+ v = (det.get("cached_tokens") if isinstance(det, dict) else getattr(det, "cached_tokens", None)) if det else None
945
+ if isinstance(v, int) and not isinstance(v, bool):
946
+ out["cache_read_tokens"] = v
947
+ break
948
+ return out
949
+
950
+
951
+ def _output_chars(resp):
952
+ n = 0
953
+ try:
954
+ txt = getattr(resp, "output_text", None)
955
+ if isinstance(txt, str):
956
+ return len(txt)
957
+ for b in (getattr(resp, "content", None) or []):
958
+ t = getattr(b, "text", None)
959
+ if isinstance(t, str):
960
+ n += len(t)
961
+ for c in (getattr(resp, "choices", None) or []):
962
+ t = getattr(getattr(c, "message", None), "content", None)
963
+ if isinstance(t, str):
964
+ n += len(t)
965
+ except Exception:
966
+ return None
967
+ return n
968
+
969
+
970
+ def _facts_from_response(resp, kwargs, salt=None):
971
+ out, seen = {}, []
972
+ if resp is None:
973
+ return out, seen
974
+ asked = kwargs.get("model")
975
+ if asked:
976
+ out["requested_model"] = str(asked)[:120]
977
+ seen.append("requested_model")
978
+ stop = getattr(resp, "stop_reason", None)
979
+ if stop is None:
980
+ try:
981
+ stop = getattr(resp.choices[0], "finish_reason", None)
982
+ except Exception:
983
+ stop = None
984
+ if stop is None and getattr(resp, "status", None) is not None and hasattr(resp, "output"):
985
+ stop = getattr(getattr(resp, "incomplete_details", None), "reason", None) or resp.status
986
+ part_types = {getattr(part, "type", None) for item in (getattr(resp, "output", None) or [])
987
+ for part in (getattr(item, "content", None) or [])}
988
+ if "refusal" in part_types:
989
+ out["refusal_part"] = True
990
+ seen.append("refusal_part")
991
+ if stop is not None:
992
+ out["stop_reason"] = str(stop)[:40]
993
+ seen.append("stop_reason")
994
+ usage = _usage_facts(resp)
995
+ out.update(usage)
996
+ seen.extend(usage)
997
+ size = _output_chars(resp)
998
+ if size is not None:
999
+ out["output_chars"] = size
1000
+ seen.append("output_chars")
1001
+ offered = _tool_names(kwargs.get("tools"))
1002
+ if offered:
1003
+ out["tools_offered"] = offered
1004
+ seen.append("tools_offered")
1005
+ invoked = _tools_invoked(resp)
1006
+ if offered or invoked:
1007
+ out["tools_invoked"] = invoked
1008
+ seen.append("tools_invoked")
1009
+ history = kwargs.get("messages") or kwargs.get("input")
1010
+ if offered:
1011
+ depth = _autonomy_depth(history)
1012
+ if depth is not None:
1013
+ out["autonomy_depth"] = depth
1014
+ seen.append("autonomy_depth")
1015
+ key = _run_key(history, salt)
1016
+ if key:
1017
+ out["agent_run_key"] = key
1018
+ seen.append("agent_run_key")
1019
+ return out, seen
1020
+
1021
+
1022
+ _TOOL_CAP = 60
1023
+
1024
+
1025
+ def _tool_names(tools):
1026
+ out = []
1027
+ for t in (tools or []):
1028
+ n = None
1029
+ if isinstance(t, dict):
1030
+ n = t.get("name") or (t.get("function") or {}).get("name")
1031
+ else:
1032
+ n = getattr(t, "name", None) or getattr(getattr(t, "function", None), "name", None)
1033
+ if n:
1034
+ out.append(str(n)[:64])
1035
+ return sorted(set(out))[:_TOOL_CAP]
1036
+
1037
+
1038
+ def _tools_invoked(resp):
1039
+ names = []
1040
+ try:
1041
+ for b in (getattr(resp, "content", None) or []):
1042
+ if getattr(b, "type", None) == "tool_use" and getattr(b, "name", None):
1043
+ names.append(str(b.name)[:64])
1044
+ except Exception:
1045
+ pass
1046
+ try:
1047
+ for tc in (getattr(resp.choices[0].message, "tool_calls", None) or []):
1048
+ fn = getattr(getattr(tc, "function", None), "name", None) or getattr(tc, "name", None)
1049
+ if fn:
1050
+ names.append(str(fn)[:64])
1051
+ except Exception:
1052
+ pass
1053
+ try:
1054
+ for item in (getattr(resp, "output", None) or []):
1055
+ if getattr(item, "type", None) in ("function_call", "custom_tool_call"):
1056
+ fn = getattr(item, "name", None)
1057
+ if fn:
1058
+ names.append(str(fn)[:64])
1059
+ except Exception:
1060
+ pass
1061
+ return sorted(set(names))[:_TOOL_CAP]
1062
+
1063
+
1064
+ def _run_key(messages, salt=None):
1065
+ if not isinstance(messages, list):
1066
+ return None
1067
+ for m in messages:
1068
+ if not isinstance(m, dict):
1069
+ try:
1070
+ m = {"role": getattr(m, "role", None), "content": getattr(m, "content", None)}
1071
+ except Exception:
1072
+ continue
1073
+ if m.get("role") != "user":
1074
+ continue
1075
+ c = m.get("content")
1076
+ if isinstance(c, list):
1077
+ parts = []
1078
+ for b in c:
1079
+ if isinstance(b, dict):
1080
+ parts.append(str(b.get("text") or b.get("content") or ""))
1081
+ else:
1082
+ parts.append(str(getattr(b, "text", "") or ""))
1083
+ c = "".join(parts)
1084
+ c = str(c or "").strip()
1085
+ if not c:
1086
+ continue
1087
+ data = c.encode("utf-8", "replace")
1088
+ if salt:
1089
+ return hmac.new(str(salt).encode("utf-8"), data, hashlib.sha256).hexdigest()[:12]
1090
+ return hashlib.sha256(data).hexdigest()[:12]
1091
+ return None
1092
+
1093
+
1094
+ def _autonomy_depth(messages):
1095
+ if not isinstance(messages, list):
1096
+ return None
1097
+ depth = 0
1098
+ for m in messages:
1099
+ if not isinstance(m, dict):
1100
+ try:
1101
+ m = {"role": getattr(m, "role", None), "content": getattr(m, "content", None),
1102
+ "tool_calls": getattr(m, "tool_calls", None)}
1103
+ except Exception:
1104
+ continue
1105
+ if m.get("role") != "assistant":
1106
+ continue
1107
+ if m.get("tool_calls"):
1108
+ depth += 1
1109
+ continue
1110
+ c = m.get("content")
1111
+ if isinstance(c, list) and any(
1112
+ (b.get("type") if isinstance(b, dict) else getattr(b, "type", None)) == "tool_use"
1113
+ for b in c):
1114
+ depth += 1
1115
+ return depth
1116
+
1117
+
1118
+ def _host_of(url):
1119
+ if not url:
1120
+ return None
1121
+ try:
1122
+ u = str(url)
1123
+ return (urlsplit(u if "://" in u else "https://" + u).hostname or None)
1124
+ except Exception:
1125
+ return None
1126
+
1127
+
1128
+ class AvowaRuntime:
1129
+ def __init__(self, ingest_key=None, ingest_secret=None, base_url=None, region=None,
1130
+ default_model=None, async_send=True, timeout=5):
1131
+ self.disabled = _disabled()
1132
+ if self.disabled:
1133
+ self.key = self.secret = self.base = self.region = self.default_model = None
1134
+ self.timeout, self.async_send, self._q, self._worker, self._pid = timeout, False, None, None, None
1135
+ return
1136
+ if not (ingest_key and ingest_secret):
1137
+ _autoload_env()
1138
+ self.key = ingest_key or os.getenv("AVOWA_RUNTIME_KEY")
1139
+ self.secret = ingest_secret or os.getenv("AVOWA_RUNTIME_SECRET")
1140
+ self.base, problem = _checked_base(base_url or os.getenv("AVOWA_BASE_URL"))
1141
+ if problem and self.key:
1142
+ _warn_once("base", problem + " — no signals will be sent. Your app is unaffected.")
1143
+ self.region = region or os.getenv("AVOWA_REGION")
1144
+ self.default_model = default_model
1145
+ self.timeout = timeout
1146
+ self.async_send = async_send
1147
+ self._q = queue.Queue(maxsize=10000) if async_send else None
1148
+ self._worker = None
1149
+ self._pid = None
1150
+ if async_send:
1151
+ self._start_worker()
1152
+ atexit.register(self.flush)
1153
+ if hasattr(os, "register_at_fork"):
1154
+ try:
1155
+ os.register_at_fork(after_in_child=self._reset_after_fork)
1156
+ except Exception:
1157
+ pass
1158
+
1159
+ def _start_worker(self):
1160
+ self._pid = os.getpid()
1161
+ self._worker = threading.Thread(target=self._run, daemon=True)
1162
+ self._worker.start()
1163
+
1164
+ def _reset_after_fork(self):
1165
+ self._q = queue.Queue(maxsize=10000)
1166
+ self._start_worker()
1167
+
1168
+ def _ensure_worker(self):
1169
+ if self._pid != os.getpid() or self._worker is None or not self._worker.is_alive():
1170
+ self._reset_after_fork()
1171
+
1172
+ def capture(self, model=None, region=None, train_on_data=None,
1173
+ guardrail_blocked=None, latency_ms=None, guardrail=None,
1174
+ decision_logged=None, human_reviewed=None, rationale_present=None,
1175
+ ai_disclosed=None, uncertainty_flagged=None, fairness_checked=None,
1176
+ blocked_category=None, pii_redacted=None, grounded=None, refused=None,
1177
+ injection_blocked=None, output_validated=None, system_prompt_protected=None,
1178
+ rate_limited=None, tool_authorized=None, action_authorized=None,
1179
+ model_pinned=None, eval_score=None,
1180
+ agent_run=None, tool_called=None, autonomy_depth=None, action_class=None,
1181
+ tools_offered=None, tools_invoked=None, agent_run_key=None,
1182
+ approval_required=None, approved_by_human=None, action_blocked=None,
1183
+ action_logged=None,
1184
+ endpoint=None, scan_prompt=None, scan_output=None,
1185
+ error=None, fallback_model=None, retries=None,
1186
+ injection_suspected=None, pii_detected=None,
1187
+ token_ceiling=None, provider_throttled=None,
1188
+ requested_model=None, stop_reason=None, structured_output=None, refusal_part=None,
1189
+ input_tokens=None, output_tokens=None, cache_read_tokens=None, cache_write_tokens=None,
1190
+ system_prompt_present=None, tool_choice=None, messages_user=None, messages_assistant=None,
1191
+ messages_tool=None, input_images=None, input_files=None, input_audio=None, streaming=None,
1192
+ response_format_type=None, prompt_chars=None, output_chars=None, client_max_retries=None,
1193
+ client_timeout_s=None, pii_families=None, injection_families=None,
1194
+ _sdk_observed=None,
1195
+ **unknown):
1196
+ """Record one inference signal (fire-and-forget).
1197
+
1198
+ Basic fields (model/region/train_on_data/guardrail_blocked) prove model provenance,
1199
+ inference region, no-train and monitoring. The optional behavioural flags below are the
1200
+ Responsible-AI moat — pass the ones your app enforces and Avowa live-verifies that principle:
1201
+ guardrail str — guardrail type applied (e.g. "pii-redact", "prompt-injection")
1202
+ decision_logged bool — this decision + inputs + user action were logged (Accountability)
1203
+ human_reviewed bool — output was routed for human approval/override (Human Oversight)
1204
+ rationale_present bool — the response carried rationale / citations (Explainability)
1205
+ ai_disclosed bool — the response disclosed it is AI (Transparency)
1206
+ uncertainty_flagged bool — the model flagged uncertainty / fell back (Reliability)
1207
+ fairness_checked bool — a fairness / bias check ran on this call (Fairness)
1208
+ blocked_category str — WHAT a guardrail caught: "prompt_injection" | "jailbreak" |
1209
+ "pii" | "toxic" | "off_topic" … (feeds live red-teaming + guardrail depth)
1210
+ pii_redacted bool — PII was detected & redacted on this call (Privacy)
1211
+ grounded bool — the answer was grounded in retrieved context (RAG) (hallucination resistance)
1212
+ refused bool — the model refused an unsafe / out-of-policy request (abuse resistance)
1213
+
1214
+ AI SECURITY — OWASP LLM Top 10 + agentic. Each is proven live from the call path; a static
1215
+ pentest or posture tool cannot show these. Pass the ones your inference path enforces:
1216
+ injection_blocked bool — a prompt-injection attempt was blocked on this call (LLM01)
1217
+ pii_redacted bool — sensitive data redacted before egress (LLM02)
1218
+ model_pinned bool — model version pinned / provider integrity verified (LLM03)
1219
+ output_validated bool — output was schema-validated / encoded before use (LLM05)
1220
+ tool_authorized bool — an agent tool call was within its allowed scope (LLM06)
1221
+ action_authorized bool — a high-impact / irreversible action was gated/approved (agentic)
1222
+ system_prompt_protected bool — system-prompt-leak attempt blocked / protection on (LLM07)
1223
+ grounded bool — answer grounded in retrieved context (also LLM09)
1224
+ rate_limited bool — a rate / token-cost ceiling was enforced on this call (LLM10)
1225
+ eval_score number — a continuous quality-eval score for this sampled call (model eval)
1226
+ AGENTIC OVERSIGHT — for products that ship agents, not just a chat box. This answers the
1227
+ questions a buyer asks about autonomy: what can it DO, under whose authority, bounded how,
1228
+ and who watched. Nothing here can be captured by a questionnaire or a posture scanner.
1229
+ agent_run bool — this call was part of an agentic run
1230
+ tool_called str — the tool actually invoked ("send_email", "post_invoice")
1231
+ autonomy_depth int — steps taken in this run without human input
1232
+ action_class str — consequence tier: "read" | "write" | "external" | "financial".
1233
+ Anything above "read" counts as consequential, and the human-
1234
+ approval rate is measured over those ONLY (approving read-only
1235
+ calls would inflate the number and prove nothing).
1236
+ approval_required bool — policy required a human before this action
1237
+ approved_by_human bool — a human actually approved it
1238
+ action_blocked bool — the action was blocked by policy in the execution path
1239
+ action_logged bool — the action was written to an attributable audit trail
1240
+
1241
+ OBSERVED fields — these are the ones Avowa grades as evidence it saw for itself (L3), rather
1242
+ than a claim your application made about itself (L2 "runtime-asserted"). Prefer these:
1243
+ endpoint str — the host you called ("api.anthropic.com"). Becomes your OBSERVED
1244
+ sub-processor list and, where the host encodes one, your observed
1245
+ inference region. Pass a host or a URL; only the hostname is sent.
1246
+ scan_prompt str — prompt text to scan LOCALLY for injection patterns. NEVER transmitted;
1247
+ only the integer count of matched pattern families is sent.
1248
+ scan_output str — output text to scan LOCALLY for PII/secrets, after your own redaction
1249
+ has run. NEVER transmitted; only the count is sent.
1250
+ error str — error class if the call failed
1251
+ fallback_model str — the model that actually served the request when the primary failed
1252
+ retries int — retry attempts made for this call
1253
+ The two scan_* arguments are read, matched, and dropped inside your process. Avowa receives
1254
+ `pii_detected` / `injection_suspected` counts and nothing else — which is what lets a leak or
1255
+ an attack be evidenced without Avowa ever processing your prompts.
1256
+
1257
+ Fields you don't pass are NOT sent — so you only ever claim what you actually instrument.
1258
+ A product with no agents simply never passes these, and its agentic claims stay
1259
+ "not applicable" rather than showing a hollow pass.
1260
+ Adversarial blocks (injection_blocked / blocked_category prompt_injection|jailbreak) + refusals
1261
+ become LIVE red-teaming + attack-surface evidence — proven on real traffic, not a report.
1262
+ """
1263
+ if unknown:
1264
+ _warn_once("unknown:" + ",".join(sorted(unknown)),
1265
+ "capture() ignored unknown field(s): %s — the rest of the signal was sent." % ", ".join(sorted(unknown)))
1266
+ if not (self.key and self.base):
1267
+ return
1268
+ evt = {"model": model or self.default_model,
1269
+ "region": region or self.region,
1270
+ "latency_ms": latency_ms}
1271
+ if train_on_data is not None:
1272
+ evt["train_on_data"] = bool(train_on_data)
1273
+ for k, v in (("guardrail_blocked", guardrail_blocked),
1274
+ ("guardrail", guardrail), ("decision_logged", decision_logged),
1275
+ ("human_reviewed", human_reviewed), ("rationale_present", rationale_present),
1276
+ ("ai_disclosed", ai_disclosed), ("uncertainty_flagged", uncertainty_flagged),
1277
+ ("fairness_checked", fairness_checked), ("blocked_category", blocked_category),
1278
+ ("pii_redacted", pii_redacted), ("grounded", grounded), ("refused", refused),
1279
+ ("injection_blocked", injection_blocked), ("output_validated", output_validated),
1280
+ ("system_prompt_protected", system_prompt_protected), ("rate_limited", rate_limited),
1281
+ ("tool_authorized", tool_authorized), ("action_authorized", action_authorized),
1282
+ ("model_pinned", model_pinned), ("eval_score", eval_score),
1283
+ ("agent_run", agent_run), ("tool_called", tool_called),
1284
+ ("autonomy_depth", autonomy_depth), ("action_class", action_class),
1285
+ ("tools_offered", tools_offered), ("tools_invoked", tools_invoked),
1286
+ ("agent_run_key", agent_run_key),
1287
+ ("approval_required", approval_required), ("approved_by_human", approved_by_human),
1288
+ ("action_blocked", action_blocked), ("action_logged", action_logged),
1289
+ ("endpoint", _host_of(endpoint)), ("error", error),
1290
+ ("fallback_model", fallback_model), ("retries", retries),
1291
+ ("token_ceiling", token_ceiling), ("provider_throttled", provider_throttled),
1292
+ ("requested_model", requested_model), ("stop_reason", stop_reason),
1293
+ ("structured_output", structured_output), ("refusal_part", refusal_part),
1294
+ ("input_tokens", input_tokens), ("output_tokens", output_tokens),
1295
+ ("cache_read_tokens", cache_read_tokens), ("cache_write_tokens", cache_write_tokens),
1296
+ ("system_prompt_present", system_prompt_present), ("tool_choice", tool_choice),
1297
+ ("messages_user", messages_user), ("messages_assistant", messages_assistant),
1298
+ ("messages_tool", messages_tool), ("input_images", input_images),
1299
+ ("input_files", input_files), ("input_audio", input_audio), ("streaming", streaming),
1300
+ ("response_format_type", response_format_type), ("prompt_chars", prompt_chars),
1301
+ ("output_chars", output_chars), ("client_max_retries", client_max_retries),
1302
+ ("client_timeout_s", client_timeout_s), ("pii_families", pii_families),
1303
+ ("injection_families", injection_families)):
1304
+ if v is not None:
1305
+ evt[k] = v
1306
+ call = _CALL_FACTS.get() or {}
1307
+ more = {**_env_facts(), **call}
1308
+ for k, v in more.items():
1309
+ evt.setdefault(k, v)
1310
+ if call or _sdk_observed:
1311
+ _sdk_observed = list(_sdk_observed or []) + list(more)
1312
+ scan = None
1313
+ if scan_prompt is not None or scan_output is not None:
1314
+ scan = (_cap(scan_prompt, "tail") if scan_prompt is not None else None,
1315
+ _cap(scan_output, "head") if scan_output is not None else None)
1316
+ if injection_suspected is not None:
1317
+ evt["injection_suspected"] = int(injection_suspected)
1318
+ if pii_detected is not None:
1319
+ evt["pii_detected"] = int(pii_detected)
1320
+ if _sdk_observed:
1321
+ evt["_sdk_observed"] = sorted(set(_sdk_observed))
1322
+ if INTERNAL_ERRORS:
1323
+ evt["sdk_errors"] = sum(INTERNAL_ERRORS.values())
1324
+ evt["sdk_error_kinds"] = sorted(INTERNAL_ERRORS)[:10]
1325
+ if self.async_send:
1326
+ self._ensure_worker()
1327
+ if scan is not None and self._q.qsize() >= DEFERRED_SCAN_MAX:
1328
+ _apply_scan(evt, scan)
1329
+ scan = None
1330
+ try:
1331
+ self._q.put_nowait((evt, scan))
1332
+ _dbg("captured " + _evt_summary(evt))
1333
+ except queue.Full:
1334
+ _warn_once("qfull", "signal queue is full — signals are being dropped. The "
1335
+ "backend is unreachable or far slower than your traffic.")
1336
+ else:
1337
+ _apply_scan(evt, scan)
1338
+ self._post(evt)
1339
+
1340
+ @contextmanager
1341
+ def track(self, model=None, region=None, train_on_data=None, **asserts):
1342
+ """Time a block and capture a signal when it exits. An exception inside the block is
1343
+ recorded as `error=<ExceptionType>` — it used to be recorded as `guardrail_blocked=True`,
1344
+ which turned every timeout and bad API key into evidence of a working guardrail. Pass
1345
+ `guardrail_blocked=True` yourself when your guardrail actually blocked the call."""
1346
+ start = time.time()
1347
+ err = None
1348
+ try:
1349
+ yield
1350
+ except Exception as e:
1351
+ err = type(e).__name__
1352
+ raise
1353
+ finally:
1354
+ self.capture(model=model, region=region, train_on_data=train_on_data, error=err,
1355
+ latency_ms=int((time.time() - start) * 1000), **asserts)
1356
+
1357
+ def instrument_openai(self, client, region=None, scan=True, asserts=None):
1358
+ """Monkey-patch an OpenAI (or Azure OpenAI) client so every model call is captured.
1359
+
1360
+ Same contract as `instrument_anthropic`: everything recorded is OBSERVED — the model the
1361
+ API actually returned, the host reached, latency and errors — plus, with `scan=True`, an
1362
+ in-process injection/PII scan that transmits counts only.
1363
+
1364
+ Covers all three call shapes, because a vendor rarely uses only one:
1365
+ • `chat.completions.create` — the common path
1366
+ • `chat.completions.parse` — structured output (beta namespace on some versions)
1367
+ • `responses.create` — the newer Responses API
1368
+ Wrapping only `chat.completions.create` would silently miss the others and under-report
1369
+ coverage, which is the same trap `parse()` set on the Anthropic side.
1370
+
1371
+ AZURE OPENAI works through this call too: it is the same client class with a different
1372
+ `base_url`, so the host recorded is the Azure endpoint — which is exactly what proves the
1373
+ inference region rather than assuming it.
1374
+
1375
+ Streaming (`stream=True`) is captured when the stream ends, on the provider's own stream
1376
+ object — the app receives exactly what it would without Avowa.
1377
+ """
1378
+ if self.disabled:
1379
+ return client
1380
+ rt = self
1381
+ host = _host_of(getattr(client, "base_url", None)) or "api.openai.com"
1382
+ client_facts = _client_facts(client)
1383
+
1384
+ def _text_of(msgs):
1385
+ parts = []
1386
+ for m in (msgs or []):
1387
+ c = m.get("content") if isinstance(m, dict) else None
1388
+ if isinstance(c, str):
1389
+ parts.append(c)
1390
+ elif isinstance(c, list):
1391
+ parts.extend(b.get("text", "") for b in c if isinstance(b, dict))
1392
+ return "\n".join(p for p in parts if p)
1393
+
1394
+ def _out_of(resp):
1395
+ try:
1396
+ txt = getattr(resp, "output_text", None)
1397
+ if txt:
1398
+ return txt
1399
+ choices = getattr(resp, "choices", None) or []
1400
+ return "\n".join((getattr(c.message, "content", "") or "") for c in choices)
1401
+ except Exception:
1402
+ return None
1403
+
1404
+ def _wrap(original, kind):
1405
+ def record(resp, err, ms, kwargs):
1406
+ try:
1407
+ model = getattr(resp, "model", None) or kwargs.get("model")
1408
+ extra = dict(asserts or {})
1409
+ facts, seen = _facts_from_response(resp, kwargs, rt.secret)
1410
+ extra.update(facts)
1411
+ req, req_seen = _facts_from_request(kwargs)
1412
+ extra.update(req); seen += req_seen
1413
+ extra.update(client_facts); seen += list(client_facts)
1414
+ if kind == "parse" or kwargs.get("response_format") is not None:
1415
+ extra["structured_output"] = True
1416
+ seen.append("structured_output")
1417
+ seen += ["model", "endpoint", "latency_ms"] + (["error"] if err else [])
1418
+ if scan:
1419
+ seen += ["pii_detected", "injection_suspected", "pii_families", "injection_families"]
1420
+ extra["_sdk_observed"] = seen
1421
+ prompt = kwargs.get("messages")
1422
+ if prompt is None and isinstance(kwargs.get("input"), list):
1423
+ prompt = kwargs.get("input")
1424
+ rt.capture(model=model, region=region or rt.region,
1425
+ latency_ms=ms, endpoint=host, error=err,
1426
+ scan_prompt=_text_of(prompt) if scan else None,
1427
+ scan_output=_out_of(resp) if (scan and resp is not None) else None,
1428
+ **extra)
1429
+ except Exception as e:
1430
+ _internal_error("openai.record", e)
1431
+ return _wrap_call(original, record, "openai." + kind)
1432
+
1433
+ targets = []
1434
+ chat = getattr(getattr(client, "chat", None), "completions", None)
1435
+ if chat is not None:
1436
+ targets += [(chat, "create", "create"), (chat, "parse", "parse")]
1437
+ beta_chat = getattr(getattr(getattr(client, "beta", None), "chat", None), "completions", None)
1438
+ if beta_chat is not None:
1439
+ targets.append((beta_chat, "parse", "parse"))
1440
+ responses = getattr(client, "responses", None)
1441
+ if responses is not None:
1442
+ targets += [(responses, "create", "create"), (responses, "parse", "parse")]
1443
+
1444
+ for holder, name, kind in targets:
1445
+ fn = getattr(holder, name, None)
1446
+ if callable(fn) and not getattr(fn, "_avowa_wrapped", False):
1447
+ try:
1448
+ setattr(holder, name, _wrap(fn, kind))
1449
+ except Exception as e:
1450
+ _internal_error(f"openai.patch.{name}", e, fatal_in_strict=False)
1451
+ return client
1452
+
1453
+ def instrument_anthropic(self, client, region=None, scan=True, asserts=None):
1454
+ """Monkey-patch an Anthropic client so every message call is captured.
1455
+
1456
+ Wraps BOTH `messages.create` and `messages.parse`. This matters: `parse()` does not route
1457
+ through `create()` — it posts directly — so wrapping only `create` would silently miss every
1458
+ structured-output call and quietly under-report your coverage.
1459
+
1460
+ Everything recorded here is OBSERVED by the SDK — the model the API actually returned, the
1461
+ host it was reached on, latency, errors and retries. With `scan=True` (default) the prompt
1462
+ and response are also scanned in-process for injection and PII patterns; only the counts are
1463
+ sent. Pass `scan=False` to disable the local scan entirely.
1464
+
1465
+ `asserts` is a dict of behavioural flags your app genuinely enforces on EVERY call through
1466
+ this client (e.g. {"model_pinned": True} when the model id is a pinned constant). They are
1467
+ recorded as self-reported, not observed — pass only what is true unconditionally.
1468
+
1469
+ A `parse()` call that supplies an `output_format` is recorded as structured output (a fact
1470
+ about the call); Avowa grades output validation from it.
1471
+
1472
+ Streaming: `messages.create(stream=True)` is captured when the stream ends, on the provider's
1473
+ own stream object (nothing is swapped in front of it). The `messages.stream()` helper is not
1474
+ wrapped — call `avowa.capture(...)` after it closes if you use it.
1475
+ """
1476
+ if self.disabled:
1477
+ return client
1478
+ rt = self
1479
+ host = _host_of(getattr(client, "base_url", None)) or "api.anthropic.com"
1480
+ client_facts = _client_facts(client)
1481
+
1482
+ def _text_of(msgs):
1483
+ parts = []
1484
+ for m in (msgs or []):
1485
+ c = m.get("content") if isinstance(m, dict) else None
1486
+ if isinstance(c, str):
1487
+ parts.append(c)
1488
+ elif isinstance(c, list):
1489
+ parts.extend(b.get("text", "") for b in c if isinstance(b, dict))
1490
+ return "\n".join(parts)
1491
+
1492
+ def _wrap(original, method_name):
1493
+ def record(resp, err, ms, kwargs):
1494
+ try:
1495
+ model = getattr(resp, "model", None) or kwargs.get("model")
1496
+ out_text = None
1497
+ if scan and resp is not None:
1498
+ try:
1499
+ out_text = "\n".join(getattr(b, "text", "") or ""
1500
+ for b in (getattr(resp, "content", None) or []))
1501
+ except Exception:
1502
+ out_text = None
1503
+ extra = dict(asserts or {})
1504
+ facts, seen = _facts_from_response(resp, kwargs, rt.secret)
1505
+ extra.update(facts)
1506
+ req, req_seen = _facts_from_request(kwargs)
1507
+ extra.update(req); seen += req_seen
1508
+ extra.update(client_facts); seen += list(client_facts)
1509
+ if method_name == "parse" and kwargs.get("output_format") is not None:
1510
+ extra["structured_output"] = True
1511
+ seen.append("structured_output")
1512
+ seen += ["model", "endpoint", "latency_ms"] + (["error"] if err else [])
1513
+ if scan:
1514
+ seen += ["pii_detected", "injection_suspected", "pii_families", "injection_families"]
1515
+ extra["_sdk_observed"] = seen
1516
+ rt.capture(model=model, region=region or rt.region,
1517
+ latency_ms=ms, endpoint=host, error=err,
1518
+ scan_prompt=_text_of(kwargs.get("messages")) if scan else None,
1519
+ scan_output=out_text, **extra)
1520
+ except Exception as e:
1521
+ _internal_error("anthropic.record", e)
1522
+ return _wrap_call(original, record, "anthropic." + method_name)
1523
+
1524
+ for name in ("create", "parse"):
1525
+ fn = getattr(getattr(client, "messages", None), name, None)
1526
+ if callable(fn) and not getattr(fn, "_avowa_wrapped", False):
1527
+ try:
1528
+ setattr(client.messages, name, _wrap(fn, name))
1529
+ except Exception as e:
1530
+ _internal_error(f"anthropic.patch.{name}", e, fatal_in_strict=False)
1531
+ return client
1532
+
1533
+ def flush(self, timeout=5):
1534
+ """Block until queued signals are sent, or until `timeout` seconds pass — whichever is
1535
+ first. Called automatically at exit. The timeout used to be ignored and `q.join()` has no
1536
+ bound of its own, so a process exiting with egress blackholed and a full queue could hang
1537
+ for up to 10,000 × the request timeout. Evidence is never worth holding an exit hostage."""
1538
+ if not (self.async_send and self._q is not None):
1539
+ return
1540
+ deadline = time.time() + max(0.0, float(timeout or 0))
1541
+ try:
1542
+ while self._q.unfinished_tasks and time.time() < deadline:
1543
+ time.sleep(0.05)
1544
+ except Exception:
1545
+ pass
1546
+
1547
+ def _run(self):
1548
+ while True:
1549
+ item = self._q.get()
1550
+ try:
1551
+ if item is None:
1552
+ return
1553
+ evt, scan = item
1554
+ _apply_scan(evt, scan)
1555
+ self._post(evt)
1556
+ except Exception as e:
1557
+ _internal_error("sender", e, fatal_in_strict=False)
1558
+ finally:
1559
+ self._q.task_done()
1560
+
1561
+ def _post(self, evt):
1562
+ body = json.dumps(evt, separators=(",", ":"))
1563
+ headers = {"Content-Type": "application/json", "X-Ingest-Key": self.key,
1564
+ "User-Agent": f"avowa-runtime-python/{__version__} (+https://avowa.ai)"}
1565
+ if self.secret:
1566
+ ts = str(int(time.time()))
1567
+ nonce = os.urandom(12).hex()
1568
+ sig = hmac.new(self.secret.encode(), f"{ts}.{nonce}.{body}".encode(), hashlib.sha256).hexdigest()
1569
+ headers["X-Avowa-Timestamp"] = ts
1570
+ headers["X-Avowa-Nonce"] = nonce
1571
+ headers["X-Avowa-Signature"] = sig
1572
+ if self.secret and _remote_on():
1573
+ headers["X-Avowa-Fields"] = _REMOTE["v"] or "none"
1574
+ url = self.base + "/ingest/runtime"
1575
+ req = urllib.request.Request(url, data=body.encode(), method="POST", headers=headers)
1576
+ try:
1577
+ resp = urllib.request.urlopen(req, timeout=self.timeout)
1578
+ raw = resp.read()
1579
+ reply = None
1580
+ if raw[:1] == b"{":
1581
+ try:
1582
+ reply = json.loads(raw)
1583
+ except Exception:
1584
+ reply = None
1585
+ if isinstance(reply, dict):
1586
+ block = reply.get("sdk_fields")
1587
+ if self.secret and _remote_on() and isinstance(block, dict):
1588
+ _accept_fields(block, self.secret)
1589
+ latest = reply.get("sdk_latest")
1590
+ if isinstance(latest, str) and re.match(r"^\d+\.\d+\.\d+$", latest):
1591
+ _warn_once("latest", "avowa_runtime %s is available (this is %s). Nothing is wrong — "
1592
+ "upgrade when convenient (your Avowa Connections page has the download)." % (latest, __version__))
1593
+ _dbg("sent %s → HTTP %s" % (_evt_summary(evt), getattr(resp, "status", None) or resp.getcode()))
1594
+ except Exception as e:
1595
+ detail = type(e).__name__
1596
+ code = getattr(e, "code", None)
1597
+ if code:
1598
+ hint = {401: "the ingest key or secret is wrong for this connection",
1599
+ 403: "this key is not permitted to write signals",
1600
+ 404: "no /ingest/runtime at this AVOWA_BASE_URL — check the URL",
1601
+ 422: "the server rejected the signal's shape"}.get(
1602
+ code, "the server rejected the signal")
1603
+ if code == 401 and self.secret:
1604
+ hint += " (or the clock is off — signatures expire after 5 minutes)"
1605
+ detail = "HTTP %s — %s" % (code, hint)
1606
+ elif isinstance(e, (OSError,)):
1607
+ detail = "%s — cannot reach %s (DNS, egress rules or the wrong host)" % (
1608
+ type(e).__name__, self.base)
1609
+ _warn_once("post:" + str(code or type(e).__name__),
1610
+ "signal NOT delivered: %s. Your app is unaffected; evidence is not being "
1611
+ "recorded. Set AVOWA_DEBUG=1 for per-call detail." % detail)
1612
+ _dbg("POST %s failed: %r" % (url, e))
1613
+ raise