zexl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zexl/__init__.py +103 -0
- zexl/_canon.py +193 -0
- zexl/_config.py +76 -0
- zexl/_context.py +210 -0
- zexl/_core.py +144 -0
- zexl/_export.py +368 -0
- zexl/_fingerprint.py +108 -0
- zexl/_hooks/__init__.py +21 -0
- zexl/_hooks/_anthropic.py +200 -0
- zexl/_hooks/_common.py +259 -0
- zexl/_hooks/_openai.py +204 -0
- zexl/_record.py +77 -0
- zexl/_stitch.py +81 -0
- zexl/auto.py +5 -0
- zexl/py.typed +0 -0
- zexl-0.1.0.dist-info/METADATA +177 -0
- zexl-0.1.0.dist-info/RECORD +19 -0
- zexl-0.1.0.dist-info/WHEEL +4 -0
- zexl-0.1.0.dist-info/licenses/LICENSE +202 -0
zexl/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Zexl SDK: records metadata about model calls. Prompt and response text never leave the process.
|
|
2
|
+
|
|
3
|
+
import zexl.auto # reads ZEXL_API_KEY and starts capturing
|
|
4
|
+
|
|
5
|
+
zexl.set_customer("acct_123") # optional
|
|
6
|
+
with zexl.run("research"): # optional: group calls into one run explicitly
|
|
7
|
+
...
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import _config
|
|
17
|
+
from ._context import feature, run, set_customer
|
|
18
|
+
from ._export import Transport
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = ["__version__", "feature", "flush", "init", "run", "set_customer", "shutdown", "status"]
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("zexl")
|
|
24
|
+
_lock = threading.Lock()
|
|
25
|
+
_runtime: Any = None
|
|
26
|
+
_patches: Any = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init(
|
|
30
|
+
api_key: str | None = None,
|
|
31
|
+
*,
|
|
32
|
+
endpoint: str | None = None,
|
|
33
|
+
environment: str | None = None,
|
|
34
|
+
_transport: Transport | None = None,
|
|
35
|
+
) -> bool:
|
|
36
|
+
"""Starts capture. Safe to call more than once. Returns whether capture is on.
|
|
37
|
+
|
|
38
|
+
Never raises: with a missing or invalid key the SDK logs one warning and stays off.
|
|
39
|
+
"""
|
|
40
|
+
global _runtime, _patches
|
|
41
|
+
with _lock:
|
|
42
|
+
if _runtime is not None:
|
|
43
|
+
return True
|
|
44
|
+
if _config.disabled_by_env():
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
config = _config.load(api_key, endpoint, environment)
|
|
48
|
+
except _config.ConfigError as e:
|
|
49
|
+
log.warning("zexl: disabled (%s)", e)
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
from . import _hooks
|
|
53
|
+
from ._core import Runtime
|
|
54
|
+
from ._export import Exporter
|
|
55
|
+
|
|
56
|
+
runtime = Runtime(config, Exporter(config, __version__, _transport))
|
|
57
|
+
_patches = _hooks.install(runtime)
|
|
58
|
+
_runtime = runtime
|
|
59
|
+
except Exception: # noqa: BLE001 - starting Zexl must never break the application
|
|
60
|
+
log.warning("zexl: disabled (could not start)", exc_info=True)
|
|
61
|
+
return False
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
66
|
+
"""Sends queued records now. Call before a short-lived process (serverless) exits."""
|
|
67
|
+
rt = _runtime
|
|
68
|
+
if rt is None:
|
|
69
|
+
return True
|
|
70
|
+
try:
|
|
71
|
+
return bool(rt.exporter.flush(timeout))
|
|
72
|
+
except Exception: # noqa: BLE001
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def shutdown() -> None:
|
|
77
|
+
"""Flushes, removes the hooks and stops the SDK."""
|
|
78
|
+
global _runtime, _patches
|
|
79
|
+
with _lock:
|
|
80
|
+
rt, patches = _runtime, _patches
|
|
81
|
+
_runtime = _patches = None
|
|
82
|
+
try:
|
|
83
|
+
if patches is not None:
|
|
84
|
+
patches.undo()
|
|
85
|
+
if rt is not None:
|
|
86
|
+
rt.exporter.shutdown()
|
|
87
|
+
except Exception: # noqa: BLE001, S110
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def status() -> dict[str, Any]:
|
|
92
|
+
"""What the SDK is doing: whether it is on, which hooks are active, and delivery counts."""
|
|
93
|
+
rt = _runtime
|
|
94
|
+
if rt is None:
|
|
95
|
+
return {"enabled": False}
|
|
96
|
+
return {
|
|
97
|
+
"enabled": True,
|
|
98
|
+
"endpoint": rt.config.endpoint,
|
|
99
|
+
"environment": rt.config.environment,
|
|
100
|
+
"hooks": dict(rt.hooks),
|
|
101
|
+
"sent": rt.exporter.sent,
|
|
102
|
+
"dropped": rt.exporter.dropped,
|
|
103
|
+
}
|
zexl/_canon.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Canonical form of chat content.
|
|
2
|
+
|
|
3
|
+
The same content must produce the same canonical value whether it arrives as a provider SDK
|
|
4
|
+
object, a dumped dict, or a hand-built dict, and whichever provider shape it uses. Rules (each
|
|
5
|
+
one was needed by a case in the M1 hash-stability spike):
|
|
6
|
+
|
|
7
|
+
- A message is a role plus an ordered list of parts: text, tool call, tool result, other.
|
|
8
|
+
- Ids are left out, so id formats cannot break a match.
|
|
9
|
+
- Tool arguments are parsed and re-serialised with sorted keys.
|
|
10
|
+
- A string and a single text part are the same content; adjacent text parts merge.
|
|
11
|
+
- Reasoning blocks are ignored, because callers may or may not send them back.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
Part = list[str]
|
|
20
|
+
Message = dict[str, Any]
|
|
21
|
+
|
|
22
|
+
_TEXT_TYPES = ("text", "input_text", "output_text")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dumps(value: Any) -> str:
|
|
26
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def plain(obj: Any) -> Any:
|
|
30
|
+
"""Pydantic models, typed dicts and plain dicts all become plain JSON-like data."""
|
|
31
|
+
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
|
32
|
+
return obj
|
|
33
|
+
if isinstance(obj, dict):
|
|
34
|
+
return {k: plain(v) for k, v in obj.items() if v is not None}
|
|
35
|
+
if isinstance(obj, (list, tuple)):
|
|
36
|
+
return [plain(v) for v in obj]
|
|
37
|
+
dump = getattr(obj, "model_dump", None)
|
|
38
|
+
if dump is not None:
|
|
39
|
+
try:
|
|
40
|
+
return dump(mode="json", exclude_none=True, warnings=False)
|
|
41
|
+
except Exception: # noqa: BLE001, S110 - fall through to a best-effort dict
|
|
42
|
+
pass
|
|
43
|
+
to_dict = getattr(obj, "__dict__", None)
|
|
44
|
+
if isinstance(to_dict, dict):
|
|
45
|
+
return {k: plain(v) for k, v in to_dict.items() if v is not None and not k.startswith("_")}
|
|
46
|
+
return str(obj)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def canon_args(value: Any) -> str:
|
|
50
|
+
"""Tool arguments as canonical JSON, whether given as a JSON string or as data."""
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(value)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return str(value)
|
|
56
|
+
return dumps(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def text_of(content: Any) -> str:
|
|
60
|
+
if content is None:
|
|
61
|
+
return ""
|
|
62
|
+
if isinstance(content, str):
|
|
63
|
+
return content
|
|
64
|
+
if not isinstance(content, list):
|
|
65
|
+
return dumps(content)
|
|
66
|
+
out: list[str] = []
|
|
67
|
+
for part in content:
|
|
68
|
+
if isinstance(part, str):
|
|
69
|
+
out.append(part)
|
|
70
|
+
elif isinstance(part, dict) and part.get("type") in _TEXT_TYPES:
|
|
71
|
+
out.append(str(part.get("text", "")))
|
|
72
|
+
else:
|
|
73
|
+
out.append(dumps(part))
|
|
74
|
+
return "".join(out)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parts_of(content: Any) -> list[Part]:
|
|
78
|
+
"""Message content (already plain) as ordered parts."""
|
|
79
|
+
if content is None:
|
|
80
|
+
return []
|
|
81
|
+
if isinstance(content, str):
|
|
82
|
+
return [["text", content]] if content else []
|
|
83
|
+
if not isinstance(content, list):
|
|
84
|
+
return [["other", dumps(content)]]
|
|
85
|
+
parts: list[Part] = []
|
|
86
|
+
for p in content:
|
|
87
|
+
if isinstance(p, str):
|
|
88
|
+
if p:
|
|
89
|
+
_add_text(parts, p)
|
|
90
|
+
continue
|
|
91
|
+
if not isinstance(p, dict):
|
|
92
|
+
parts.append(["other", dumps(p)])
|
|
93
|
+
continue
|
|
94
|
+
t = p.get("type")
|
|
95
|
+
if t in _TEXT_TYPES:
|
|
96
|
+
if p.get("text"):
|
|
97
|
+
_add_text(parts, str(p["text"]))
|
|
98
|
+
elif t == "tool_use":
|
|
99
|
+
parts.append(["tool_call", str(p.get("name", "")), canon_args(p.get("input", {}))])
|
|
100
|
+
elif t == "tool_result":
|
|
101
|
+
parts.append(["tool_result", text_of(p.get("content"))])
|
|
102
|
+
elif t in ("thinking", "redacted_thinking"):
|
|
103
|
+
continue
|
|
104
|
+
else:
|
|
105
|
+
rest = {k: v for k, v in p.items() if k not in ("id", "cache_control")}
|
|
106
|
+
parts.append(["other", dumps(rest)])
|
|
107
|
+
return parts
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _add_text(parts: list[Part], text: str) -> None:
|
|
111
|
+
if parts and parts[-1][0] == "text":
|
|
112
|
+
parts[-1][1] += text
|
|
113
|
+
else:
|
|
114
|
+
parts.append(["text", text])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def canon_message(msg: Any) -> Message:
|
|
118
|
+
"""One chat message (OpenAI or Anthropic shape, object or dict)."""
|
|
119
|
+
m = plain(msg)
|
|
120
|
+
if not isinstance(m, dict):
|
|
121
|
+
return {"role": "", "parts": [["other", dumps(m)]]}
|
|
122
|
+
role = str(m.get("role", ""))
|
|
123
|
+
if role == "tool": # OpenAI tool result message
|
|
124
|
+
return {"role": role, "parts": [["tool_result", text_of(m.get("content"))]]}
|
|
125
|
+
parts = parts_of(m.get("content"))
|
|
126
|
+
for tc in m.get("tool_calls") or []: # OpenAI assistant tool calls
|
|
127
|
+
fn = (tc.get("function") if isinstance(tc, dict) else None) or {}
|
|
128
|
+
parts.append(["tool_call", str(fn.get("name", "")), canon_args(fn.get("arguments", "{}"))])
|
|
129
|
+
return {"role": role, "parts": parts}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def canon_messages(messages: Any) -> list[Message]:
|
|
133
|
+
if isinstance(messages, str):
|
|
134
|
+
return [{"role": "user", "parts": [["text", messages]]}]
|
|
135
|
+
out = [canon_message(m) for m in messages or []]
|
|
136
|
+
# An assistant message with no content carries nothing and is dropped by some callers.
|
|
137
|
+
return [m for m in out if m["parts"] or m["role"] != "assistant"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def canon_response_items(items: Any) -> list[Message]:
|
|
141
|
+
"""OpenAI Responses `input` or `output` (a string or an item list)."""
|
|
142
|
+
if items is None:
|
|
143
|
+
return []
|
|
144
|
+
if isinstance(items, str):
|
|
145
|
+
return [{"role": "user", "parts": [["text", items]]}] if items else []
|
|
146
|
+
out: list[Message] = []
|
|
147
|
+
for item in plain(items):
|
|
148
|
+
if not isinstance(item, dict):
|
|
149
|
+
out.append({"role": "other", "parts": [["other", dumps(item)]]})
|
|
150
|
+
continue
|
|
151
|
+
t = item.get("type", "message")
|
|
152
|
+
if t == "message":
|
|
153
|
+
out.append({"role": str(item.get("role", "")), "parts": parts_of(item.get("content"))})
|
|
154
|
+
elif t == "function_call":
|
|
155
|
+
call = ["tool_call", str(item.get("name", "")), canon_args(item.get("arguments", "{}"))]
|
|
156
|
+
out.append({"role": "assistant", "parts": [call]})
|
|
157
|
+
elif t == "function_call_output":
|
|
158
|
+
out.append({"role": "tool", "parts": [["tool_result", text_of(item.get("output"))]]})
|
|
159
|
+
elif t == "reasoning":
|
|
160
|
+
continue
|
|
161
|
+
else:
|
|
162
|
+
rest = {k: v for k, v in item.items() if k not in ("id", "status")}
|
|
163
|
+
out.append({"role": "other", "parts": [["other", dumps(rest)]]})
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def canon_system(system: Any) -> list[Part]:
|
|
168
|
+
return parts_of(plain(system))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def canon_tools(tools: Any) -> list[Any]:
|
|
172
|
+
plain_tools = [plain(t) for t in tools or []]
|
|
173
|
+
return sorted(plain_tools, key=dumps)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def text_chars(messages: list[Message]) -> int:
|
|
177
|
+
return sum(len(p[-1]) for m in messages for p in m["parts"])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def message_text(message: Message) -> str:
|
|
181
|
+
return "".join(p[1] for p in message["parts"] if p[0] == "text")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def tool_calls(message: Message) -> list[tuple[str, str]]:
|
|
185
|
+
return [(p[1], p[2]) for p in message["parts"] if p[0] == "tool_call"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def split_at_last_assistant(messages: list[Message]) -> int:
|
|
189
|
+
"""Index just past the last assistant message; 0 when there is none."""
|
|
190
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
191
|
+
if messages[i]["role"] == "assistant":
|
|
192
|
+
return i + 1
|
|
193
|
+
return 0
|
zexl/_config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Configuration and the two-part API key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENDPOINT = "https://zexl-intake.fly.dev"
|
|
13
|
+
_KEY_RE = re.compile(r"^(zx_[A-Za-z0-9]+_[A-Za-z0-9]+)\.([A-Za-z0-9_\-]{22,})$")
|
|
14
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ValueError):
|
|
18
|
+
"""The SDK cannot start with the given settings. Never raised into customer calls."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Config:
|
|
23
|
+
token: str
|
|
24
|
+
"""The part of the key that is sent to Zexl for authentication."""
|
|
25
|
+
salt: bytes
|
|
26
|
+
"""The part of the key that never leaves this process. Keys every hash."""
|
|
27
|
+
endpoint: str
|
|
28
|
+
environment: str
|
|
29
|
+
openai_stream_usage: bool
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def salt_check(self) -> str:
|
|
33
|
+
"""Short value that lets the server notice two keys with different salts. Reveals nothing
|
|
34
|
+
useful about the salt: 32 bits of an HMAC keyed with 128+ bits of entropy."""
|
|
35
|
+
return hmac.new(self.salt, b"zexl-salt-check", hashlib.sha256).hexdigest()[:8]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_key(key: str) -> tuple[str, bytes]:
|
|
39
|
+
m = _KEY_RE.match(key.strip())
|
|
40
|
+
if not m:
|
|
41
|
+
if key.strip().startswith("zx_") and "." not in key:
|
|
42
|
+
raise ConfigError(
|
|
43
|
+
"ZEXL_API_KEY has no salt part. Use the full key shown at creation "
|
|
44
|
+
"(zx_<id>_<secret>.<salt>); the part after the dot stays on this machine."
|
|
45
|
+
)
|
|
46
|
+
raise ConfigError("ZEXL_API_KEY is not a valid Zexl key")
|
|
47
|
+
token, salt_text = m.group(1), m.group(2)
|
|
48
|
+
try:
|
|
49
|
+
salt = base64.urlsafe_b64decode(salt_text + "=" * (-len(salt_text) % 4))
|
|
50
|
+
except ValueError:
|
|
51
|
+
salt = salt_text.encode()
|
|
52
|
+
if len(salt) < 16:
|
|
53
|
+
raise ConfigError("ZEXL_API_KEY salt part is too short")
|
|
54
|
+
return token, salt
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load(
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
endpoint: str | None = None,
|
|
60
|
+
environment: str | None = None,
|
|
61
|
+
) -> Config:
|
|
62
|
+
key = api_key or os.environ.get("ZEXL_API_KEY", "")
|
|
63
|
+
if not key:
|
|
64
|
+
raise ConfigError("ZEXL_API_KEY is not set")
|
|
65
|
+
token, salt = parse_key(key)
|
|
66
|
+
return Config(
|
|
67
|
+
token=token,
|
|
68
|
+
salt=salt,
|
|
69
|
+
endpoint=(endpoint or os.environ.get("ZEXL_ENDPOINT") or DEFAULT_ENDPOINT).rstrip("/"),
|
|
70
|
+
environment=(environment or os.environ.get("ZEXL_ENV") or "production")[:64],
|
|
71
|
+
openai_stream_usage=os.environ.get("ZEXL_OPENAI_STREAM_USAGE", "").lower() in _TRUE,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def disabled_by_env() -> bool:
|
|
76
|
+
return os.environ.get("ZEXL_DISABLED", "").lower() in _TRUE
|
zexl/_context.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Run, feature and customer labels carried in context variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import sysconfig
|
|
11
|
+
import threading
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from types import CodeType, TracebackType
|
|
15
|
+
from typing import Any, TypeVar
|
|
16
|
+
|
|
17
|
+
from ._stitch import new_run_id
|
|
18
|
+
|
|
19
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
20
|
+
|
|
21
|
+
_LABEL_MAX = 128
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _RunState:
|
|
25
|
+
__slots__ = ("run_id", "_step", "_lock")
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.run_id = new_run_id()
|
|
29
|
+
self._step = 0
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def next_step(self) -> int:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._step += 1
|
|
35
|
+
return self._step
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_run: contextvars.ContextVar[_RunState | None] = contextvars.ContextVar("zexl_run", default=None)
|
|
39
|
+
_feature: contextvars.ContextVar[str | None] = contextvars.ContextVar("zexl_feature", default=None)
|
|
40
|
+
_customer: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
41
|
+
"zexl_customer", default=None
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clean_label(value: Any) -> str:
|
|
46
|
+
text = " ".join(str(value).split())
|
|
47
|
+
return text[:_LABEL_MAX]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Scope:
|
|
51
|
+
"""Context manager and decorator that sets context variables for its body."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, new_run: bool, feature: str | None) -> None:
|
|
54
|
+
self._new_run = new_run
|
|
55
|
+
self._feature = clean_label(feature) if feature else None
|
|
56
|
+
self._tokens: list[tuple[contextvars.ContextVar[Any], contextvars.Token[Any]]] = []
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> _Scope:
|
|
59
|
+
if self._new_run:
|
|
60
|
+
self._tokens.append((_run, _run.set(_RunState())))
|
|
61
|
+
if self._feature:
|
|
62
|
+
self._tokens.append((_feature, _feature.set(self._feature)))
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(
|
|
66
|
+
self,
|
|
67
|
+
exc_type: type[BaseException] | None,
|
|
68
|
+
exc: BaseException | None,
|
|
69
|
+
tb: TracebackType | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
while self._tokens:
|
|
72
|
+
var, token = self._tokens.pop()
|
|
73
|
+
try:
|
|
74
|
+
var.reset(token)
|
|
75
|
+
except ValueError:
|
|
76
|
+
# Exited in a different context (for example a generator moved between tasks).
|
|
77
|
+
var.set(None)
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> _Scope:
|
|
80
|
+
return self.__enter__()
|
|
81
|
+
|
|
82
|
+
async def __aexit__(
|
|
83
|
+
self,
|
|
84
|
+
exc_type: type[BaseException] | None,
|
|
85
|
+
exc: BaseException | None,
|
|
86
|
+
tb: TracebackType | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.__exit__(exc_type, exc, tb)
|
|
89
|
+
|
|
90
|
+
def __call__(self, fn: F) -> F:
|
|
91
|
+
new_run, feature = self._new_run, self._feature or clean_label(fn.__qualname__)
|
|
92
|
+
if inspect.iscoroutinefunction(fn):
|
|
93
|
+
|
|
94
|
+
@functools.wraps(fn)
|
|
95
|
+
async def awrapper(*a: Any, **kw: Any) -> Any:
|
|
96
|
+
with _Scope(new_run, feature):
|
|
97
|
+
return await fn(*a, **kw)
|
|
98
|
+
|
|
99
|
+
return awrapper # type: ignore[return-value]
|
|
100
|
+
|
|
101
|
+
@functools.wraps(fn)
|
|
102
|
+
def wrapper(*a: Any, **kw: Any) -> Any:
|
|
103
|
+
with _Scope(new_run, feature):
|
|
104
|
+
return fn(*a, **kw)
|
|
105
|
+
|
|
106
|
+
return wrapper # type: ignore[return-value]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run(name: str | None = None, *, feature: str | None = None) -> _Scope:
|
|
110
|
+
"""Marks one unit of agent work. Calls inside share a run id and count steps from 1.
|
|
111
|
+
|
|
112
|
+
Use as `with zexl.run("research"):` or as a decorator. `name` is used as the feature label
|
|
113
|
+
unless `feature` is given.
|
|
114
|
+
"""
|
|
115
|
+
return _Scope(True, feature or name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def feature(name: str) -> _Scope:
|
|
119
|
+
"""Sets the feature label for calls inside, without starting a run."""
|
|
120
|
+
return _Scope(False, name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def set_customer(customer_id: str | None) -> None:
|
|
124
|
+
"""Attributes later calls in this context to a customer. Pass an id, not personal data."""
|
|
125
|
+
_customer.set(clean_label(customer_id) if customer_id else None)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def current_customer() -> str | None:
|
|
129
|
+
return _customer.get()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def explicit_run() -> _RunState | None:
|
|
133
|
+
return _run.get()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def explicit_feature() -> str | None:
|
|
137
|
+
return _feature.get()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- ambient trace
|
|
141
|
+
|
|
142
|
+
_trace_steps: OrderedDict[str, int] = OrderedDict()
|
|
143
|
+
_trace_lock = threading.Lock()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ambient_trace() -> tuple[str, int, str | None] | None:
|
|
147
|
+
"""(trace id, step, http route) of the application's own active OpenTelemetry span, if any."""
|
|
148
|
+
otel = sys.modules.get("opentelemetry.trace")
|
|
149
|
+
if otel is None:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
span = otel.get_current_span()
|
|
153
|
+
ctx = span.get_span_context()
|
|
154
|
+
if not ctx.is_valid:
|
|
155
|
+
return None
|
|
156
|
+
trace_id = format(ctx.trace_id, "032x")
|
|
157
|
+
attrs = getattr(span, "attributes", None) or {}
|
|
158
|
+
route = attrs.get("http.route")
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
return None
|
|
161
|
+
with _trace_lock:
|
|
162
|
+
step = _trace_steps.get(trace_id, 0) + 1
|
|
163
|
+
_trace_steps[trace_id] = step
|
|
164
|
+
_trace_steps.move_to_end(trace_id)
|
|
165
|
+
while len(_trace_steps) > 4096:
|
|
166
|
+
_trace_steps.popitem(last=False)
|
|
167
|
+
return trace_id, step, clean_label(route) if route else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- caller frame
|
|
171
|
+
|
|
172
|
+
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
173
|
+
_STDLIB = sysconfig.get_paths().get("stdlib", "\0")
|
|
174
|
+
_frame_cache: dict[CodeType, str | None] = {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _frame_label(code: CodeType) -> str | None:
|
|
178
|
+
filename = code.co_filename
|
|
179
|
+
if (
|
|
180
|
+
filename.startswith(("<", _PKG_DIR, _STDLIB))
|
|
181
|
+
or "site-packages" in filename
|
|
182
|
+
or "dist-packages" in filename
|
|
183
|
+
):
|
|
184
|
+
return None
|
|
185
|
+
module = os.path.splitext(os.path.basename(filename))[0]
|
|
186
|
+
name = getattr(code, "co_qualname", code.co_name)
|
|
187
|
+
if name == "<module>":
|
|
188
|
+
return clean_label(module)
|
|
189
|
+
return clean_label(f"{module}.{name}".replace(".<locals>", ""))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def caller_feature() -> str | None:
|
|
193
|
+
"""`module.function` of the nearest frame that is application code."""
|
|
194
|
+
try:
|
|
195
|
+
frame = sys._getframe(2)
|
|
196
|
+
except ValueError:
|
|
197
|
+
return None
|
|
198
|
+
depth = 0
|
|
199
|
+
while frame is not None and depth < 80:
|
|
200
|
+
code = frame.f_code
|
|
201
|
+
if code not in _frame_cache:
|
|
202
|
+
if len(_frame_cache) > 2048:
|
|
203
|
+
_frame_cache.clear()
|
|
204
|
+
_frame_cache[code] = _frame_label(code)
|
|
205
|
+
label = _frame_cache[code]
|
|
206
|
+
if label:
|
|
207
|
+
return label
|
|
208
|
+
frame = frame.f_back # type: ignore[assignment]
|
|
209
|
+
depth += 1
|
|
210
|
+
return None
|