lucida-analytics 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.
lucida/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """Lucida server SDK."""
2
+
3
+
4
+ from ._ai_config import AiConfig, ai_config
5
+ from ._agent import AgentConversation
6
+ from ._build import BeforeSend, OnError
7
+ from ._context import (
8
+ DISTINCT_ID_HEADER,
9
+ SESSION_ID_HEADER,
10
+ Context,
11
+ client_ip,
12
+ get_context,
13
+ identify_context,
14
+ new_context,
15
+ read_tracing_headers,
16
+ request_context_args,
17
+ request_properties,
18
+ scoped,
19
+ set_context_session,
20
+ tag,
21
+ )
22
+ from ._core import VERSION as __version__
23
+ from ._core import ImportEvent, LucidaError
24
+ from ._flagcache import FlagDefinitionCacheProvider, InMemoryFlagDefinitionCache
25
+ from ._flagcache_kv import RedisFlagDefinitionCache, RedisLike
26
+ from ._flags import FlagDetails, FlagValue
27
+ from ._snapshot import FlagSnapshot, device_scope, flag_event_props
28
+ from ._global import delegate as _delegate
29
+ from ._global import shutdown as shutdown
30
+ from .aio import AsyncLucida
31
+ from .client import Lucida
32
+
33
+ # Module-level facade: set these, then call lucida.capture(...) etc. (see README "Global client").
34
+ api_key: str | None = None
35
+ host: str | None = None
36
+ secret_key: str | None = None
37
+ debug: bool | None = None
38
+ disabled: bool | None = None
39
+ flush_at: int | None = None
40
+ flush_interval: float | None = None
41
+ max_batch_size: int | None = None
42
+ before_send: BeforeSend | None = None
43
+ on_error: OnError | None = None
44
+ sync_mode: bool | None = None
45
+ disable_geoip: bool | None = None
46
+ flag_timeout: float | None = None
47
+ default_properties: dict[str, object] | None = None
48
+ timeout: float | None = None
49
+
50
+ capture = _delegate("capture")
51
+ capture_immediate = _delegate("capture_immediate")
52
+ identify = _delegate("identify")
53
+ alias = _delegate("alias")
54
+ group_identify = _delegate("group_identify")
55
+ flush = _delegate("flush")
56
+ register = _delegate("register")
57
+ unregister = _delegate("unregister")
58
+ get_feature_flag = _delegate("get_feature_flag")
59
+ is_feature_enabled = _delegate("is_feature_enabled")
60
+ get_feature_flag_payload = _delegate("get_feature_flag_payload")
61
+ get_ai_config = _delegate("get_ai_config")
62
+ get_all_flags = _delegate("get_all_flags")
63
+ get_remote_config_payload = _delegate("get_remote_config_payload")
64
+ evaluate_flags = _delegate("evaluate_flags")
65
+
66
+ __all__: list[str] = [
67
+ "AgentConversation", "DISTINCT_ID_HEADER", "SESSION_ID_HEADER", "AsyncLucida", "Context", "FlagDefinitionCacheProvider",
68
+ "FlagDetails", "FlagValue", "ImportEvent", "InMemoryFlagDefinitionCache", "Lucida", "LucidaError", "RedisFlagDefinitionCache", "RedisLike", "__version__",
69
+ "client_ip", "get_context", "identify_context", "new_context", "read_tracing_headers", "request_context_args", "request_properties",
70
+ "scoped", "set_context_session", "tag", "shutdown", "FlagSnapshot", "device_scope", "flag_event_props",
71
+ "AiConfig", "ai_config",
72
+ ] # fmt: skip
lucida/_agent.py ADDED
@@ -0,0 +1,257 @@
1
+ """Agent analytics (P-AGENT): conversation events for in-product AI agents and chatbots.
2
+
3
+ Product side only: no tokens, latency, cost or model names (Watchtower owns LLM observability). The only
4
+ Watchtower touchpoint is the optional `$ai_trace_id`. Helpers match packages/sdk-core/src/agent.ts and the
5
+ vectors in testdata/agent-analytics/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import uuid
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ Props = dict[str, Any]
16
+ Emit = Callable[[str, Props], None]
17
+
18
+ ROLES = ("user", "assistant")
19
+ OUTCOMES = ("resolved", "unresolved", "escalated", "abandoned", "error")
20
+ FEEDBACK_KINDS = ("thumbs", "rating", "comment")
21
+ MAX_TEXT, MAX_COMMENT = 4000, 2000
22
+
23
+ _TOKEN = re.compile(r"[^\W_]+")
24
+ _TRACE = re.compile(r"[0-9a-f]{32}")
25
+ _PUNCT = re.compile(r"[.,!?;:]")
26
+ _WS = re.compile(r"\s+")
27
+ _FNV_OFFSET, _FNV_PRIME, _MASK = 0xCBF29CE484222325, 0x100000001B3, (1 << 64) - 1
28
+
29
+ NEG = (
30
+ "wrong", "useless", "not helpful", "unhelpful", "doesn't work", "does not work", "didn't help",
31
+ "did not help", "not what i asked", "that's not it", "stupid", "terrible", "awful", "frustrating",
32
+ "frustrated", "annoying", "worst", "hate", "incorrect", "nonsense", "confusing", "makes no sense",
33
+ "real person", "human agent", "talk to a human", "speak to a human", "wtf", "ugh",
34
+ ) # fmt: skip
35
+ POS = ("thanks", "thank you", "great", "perfect", "awesome", "helpful", "that worked", "works now", "solved", "exactly")
36
+
37
+
38
+ def _fnv1a64(token: str) -> int:
39
+ h = _FNV_OFFSET
40
+ for b in token.encode("utf-8"):
41
+ h = ((h ^ b) * _FNV_PRIME) & _MASK
42
+ return h
43
+
44
+
45
+ def text_hash(text: str) -> str | None:
46
+ """simhash64 of the lowercased letter/digit tokens (>= 2 code points), 16 hex chars; None without tokens."""
47
+ tokens = [t for t in _TOKEN.findall(text.lower()) if len(t) >= 2]
48
+ if not tokens:
49
+ return None
50
+ v = [0] * 64
51
+ for t in tokens:
52
+ h = _fnv1a64(t)
53
+ for i in range(64):
54
+ v[i] += 1 if (h >> i) & 1 else -1
55
+ out = sum(1 << i for i in range(64) if v[i] > 0)
56
+ return f"{out:016x}"
57
+
58
+
59
+ def sentiment(text: str) -> int:
60
+ """-1 / 0 / 1 from the shared phrase lexicon (testdata/agent-analytics/lexicon.json)."""
61
+ norm = _PUNCT.sub(" ", text.lower().replace("’", "'"))
62
+ padded = " " + _WS.sub(" ", norm).strip() + " "
63
+ neg = 0
64
+ for p in NEG:
65
+ if f" {p} " in padded:
66
+ neg += 1
67
+ padded = padded.replace(f" {p} ", " ")
68
+ pos = sum(1 for p in POS if f" {p} " in padded)
69
+ return -1 if neg > pos else 1 if pos > neg else 0
70
+
71
+
72
+ def _digits(s: str) -> int:
73
+ return sum(1 for c in s if "0" <= c <= "9")
74
+
75
+
76
+ def _luhn(s: str) -> bool:
77
+ d = [ord(c) - 48 for c in s if "0" <= c <= "9"]
78
+ if not 13 <= len(d) <= 19:
79
+ return False
80
+ total = 0
81
+ for i, x in enumerate(reversed(d)):
82
+ total += (x * 2 - 9 if x > 4 else x * 2) if i % 2 == 1 else x
83
+ return total % 10 == 0
84
+
85
+
86
+ # Same detectors, order and replacement as crates/lc-replay/src/mask.rs (the worker re-applies it).
87
+ _RULES: tuple[tuple[re.Pattern[str], Callable[[str], bool]], ...] = (
88
+ (re.compile(r"\b\d(?:[ -]?\d){12,18}\b"), _luhn),
89
+ (re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}"), lambda _: True),
90
+ (re.compile(r"\+?\d[\d\s().-]{7,}\d"), lambda s: 10 <= _digits(s) <= 15),
91
+ )
92
+
93
+
94
+ def stars(s: str) -> str:
95
+ return "".join(c if c.isspace() else "*" for c in s)
96
+
97
+
98
+ def redact(text: str) -> str | None:
99
+ """Masked copy (cards, emails, phones -> `*`, whitespace kept); None when nothing matched."""
100
+ cur = text
101
+ for rx, ok in _RULES:
102
+
103
+ def mask(m: re.Match[str], ok: Callable[[str], bool] = ok) -> str:
104
+ return stars(m[0]) if ok(m[0]) else m[0]
105
+
106
+ cur = rx.sub(mask, cur)
107
+ return None if cur == text else cur
108
+
109
+
110
+ def trace_id(v: object) -> str | None:
111
+ """A W3C trace id (32 lowercase hex) or None."""
112
+ if not isinstance(v, str):
113
+ return None
114
+ t = v.lower()
115
+ return t if _TRACE.fullmatch(t) else None
116
+
117
+
118
+ def outcome(v: object) -> str | None:
119
+ return v if isinstance(v, str) and v in OUTCOMES else None
120
+
121
+
122
+ def _label(v: object, n: int) -> str | None:
123
+ return v[:n] if isinstance(v, str) and v else None
124
+
125
+
126
+ def _text_props(key: str, text: str, cap: int) -> Props:
127
+ masked = redact(text[:cap])
128
+ return {key: masked if masked is not None else text[:cap], **({"$ai_text_redacted": True} if masked is not None else {})}
129
+
130
+
131
+ def message_props(role: str, text: str, index: int, capture_text: bool) -> Props | None:
132
+ """`$ai_message` props, None for a role other than user/assistant."""
133
+ if role not in ROLES:
134
+ return None
135
+ p: Props = {"$ai_role": role, "$ai_message_index": index, "$ai_text_length": len(text)}
136
+ if role == "user":
137
+ h = text_hash(text)
138
+ if h is not None:
139
+ p["$ai_text_hash"] = h
140
+ p["$ai_sentiment"] = sentiment(text)
141
+ if capture_text:
142
+ p.update(_text_props("$ai_text", text, MAX_TEXT))
143
+ return p
144
+
145
+
146
+ def feedback_props(
147
+ kind: str, value: float | None, comment: str | None, message_index: int | None, capture_text: bool
148
+ ) -> Props | None:
149
+ """`$ai_feedback` props, None when kind/value is invalid (thumbs 1|-1, rating integer 1..5)."""
150
+ if kind == "thumbs" and value not in (1, -1):
151
+ return None
152
+ if kind == "rating" and (isinstance(value, bool) or not isinstance(value, (int, float)) or not 1 <= value <= 5 or value != int(value)):
153
+ return None
154
+ if kind not in FEEDBACK_KINDS:
155
+ return None
156
+ p: Props = {"$ai_feedback_kind": kind}
157
+ if kind != "comment":
158
+ p["$ai_feedback_value"] = int(value) # type: ignore[arg-type]
159
+ if comment and capture_text:
160
+ masked = redact(comment[:MAX_COMMENT])
161
+ p["$ai_feedback_comment"] = masked if masked is not None else comment[:MAX_COMMENT]
162
+ if message_index is not None:
163
+ p["$ai_message_index"] = message_index
164
+ return p
165
+
166
+
167
+ def outcome_props(o: str | None, resolution: str | None) -> Props:
168
+ p: Props = {}
169
+ if (v := outcome(o)) is not None:
170
+ p["$ai_outcome"] = v
171
+ if (r := _label(resolution, 64)) is not None:
172
+ p["$ai_resolution"] = r
173
+ return p
174
+
175
+
176
+ def common_props(
177
+ conversation_id: str | None = None,
178
+ agent_name: str | None = None,
179
+ agent_version: str | None = None,
180
+ trace: str | None = None,
181
+ topic: str | None = None,
182
+ use_case: str | None = None,
183
+ ) -> Props:
184
+ p: Props = {"$ai_conversation_id": conversation_id or str(uuid.uuid4())}
185
+ for key, v, n in (("$ai_agent_name", agent_name, 128), ("$ai_agent_version", agent_version, 64),
186
+ ("$ai_topic", topic, 128), ("$ai_use_case", use_case, 128)): # fmt: skip
187
+ if (s := _label(v, n)) is not None:
188
+ p[key] = s
189
+ if (t := trace_id(trace)) is not None:
190
+ p["$ai_trace_id"] = t
191
+ return p
192
+
193
+
194
+ class AgentConversation:
195
+ """One agent conversation; every event goes through the client's normal `capture`."""
196
+
197
+ def __init__(self, emit: Emit, common: Props, capture_text: bool) -> None:
198
+ self._emit, self._common, self._capture_text = emit, common, capture_text
199
+ self._next = 0
200
+
201
+ @property
202
+ def id(self) -> str:
203
+ return str(self._common["$ai_conversation_id"])
204
+
205
+ def _send(self, event: str, props: Props) -> None:
206
+ self._emit(event, {**self._common, **props})
207
+
208
+ def start(self) -> AgentConversation:
209
+ self._send("$ai_conversation_started", {})
210
+ return self
211
+
212
+ def message(self, role: str, text: str, index: int | None = None) -> bool:
213
+ """`$ai_message`; the index auto-increments per conversation unless given. False (no event) for a bad role."""
214
+ i = self._next if index is None else index
215
+ p = message_props(role, text, i, self._capture_text)
216
+ if p is None:
217
+ return False
218
+ self._next = i + 1
219
+ self._send("$ai_message", p)
220
+ return True
221
+
222
+ def feedback(
223
+ self, kind: str, value: float | None = None, comment: str | None = None, message_index: int | None = None
224
+ ) -> bool:
225
+ p = feedback_props(kind, value, comment, message_index, self._capture_text)
226
+ if p is None:
227
+ return False
228
+ self._send("$ai_feedback", p)
229
+ return True
230
+
231
+ def end(self, outcome: str | None = None, resolution: str | None = None) -> None:
232
+ self._send("$ai_conversation_ended", outcome_props(outcome, resolution))
233
+
234
+ def outcome(self, outcome: str, resolution: str | None = None) -> bool:
235
+ """A resolution known later (e.g. the support ticket closed). False (no event) for an unknown outcome."""
236
+ p = outcome_props(outcome, resolution)
237
+ if "$ai_outcome" not in p:
238
+ return False
239
+ self._send("$ai_outcome", p)
240
+ return True
241
+
242
+
243
+ def start_conversation(
244
+ capture: Callable[..., None],
245
+ distinct_id: str | None,
246
+ capture_text: bool,
247
+ *,
248
+ conversation_id: str | None = None,
249
+ agent_name: str | None = None,
250
+ agent_version: str | None = None,
251
+ trace_id: str | None = None,
252
+ topic: str | None = None,
253
+ use_case: str | None = None,
254
+ ) -> AgentConversation:
255
+ """Shared by the sync and asyncio clients: `capture(distinct_id, event, props)` is the client's normal capture."""
256
+ common = common_props(conversation_id, agent_name, agent_version, trace_id, topic, use_case)
257
+ return AgentConversation(lambda event, props: capture(distinct_id, event, props), common, capture_text).start()
lucida/_ai_config.py ADDED
@@ -0,0 +1,65 @@
1
+ """AI Config flags (LC-FLG-25, P-M49): render the served variation of an `ai_config` flag.
2
+
3
+ A variation is completion mode (chat ``messages``) or agent mode (``instructions`` plus ``tools``).
4
+ The SDK only selects and renders it; evaluating model output is Watchtower's job. Mirrors
5
+ ``packages/sdk-core/src/ai-config.ts``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field, replace
12
+ from typing import Any
13
+
14
+ _VAR = re.compile(r"\{\{\s*([\w.-]+)\s*\}\}")
15
+
16
+
17
+ def _fill(s: str, variables: dict[str, str]) -> str:
18
+ return _VAR.sub(lambda m: variables.get(m.group(1), m.group(0)), s)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AiConfig:
23
+ """A rendered AI config. ``enabled`` is False when the flag served nothing and this is the fallback."""
24
+
25
+ model: str
26
+ enabled: bool = False
27
+ mode: str = "completion"
28
+ provider: str | None = None
29
+ messages: list[dict[str, str]] = field(default_factory=list)
30
+ instructions: str | None = None
31
+ tools: list[dict[str, Any]] = field(default_factory=list)
32
+ parameters: dict[str, Any] = field(default_factory=dict)
33
+ variation: str | None = None
34
+ version: int | None = None
35
+
36
+
37
+ def ai_config(variant: str | None, payload: Any, fallback: AiConfig, variables: dict[str, str] | None = None) -> AiConfig:
38
+ """The config served as ``variant`` with ``payload`` (or ``fallback``), ``{{var}}`` placeholders filled."""
39
+ v = variables or {}
40
+ p = payload if isinstance(payload, dict) else None
41
+ if variant and p is not None and isinstance(p.get("model"), str):
42
+ msgs = p.get("messages")
43
+ tools = p.get("tools")
44
+ instr = p.get("instructions")
45
+ version = p.get("version")
46
+ base = AiConfig(
47
+ model=p["model"],
48
+ enabled=True,
49
+ mode="agent" if p.get("mode") == "agent" else "completion",
50
+ provider=p.get("provider") or None,
51
+ messages=list(msgs) if isinstance(msgs, list) else [],
52
+ instructions=instr if isinstance(instr, str) else None,
53
+ tools=list(tools) if isinstance(tools, list) else [],
54
+ parameters=dict(p.get("parameters") or {}),
55
+ variation=variant,
56
+ version=version if isinstance(version, int) and not isinstance(version, bool) else None,
57
+ )
58
+ else:
59
+ mode = fallback.mode if fallback.mode != "completion" or fallback.instructions is None else "agent"
60
+ base = replace(fallback, enabled=False, mode=mode)
61
+ return replace(
62
+ base,
63
+ messages=[{"role": m["role"], "content": _fill(m["content"], v)} for m in base.messages],
64
+ instructions=_fill(base.instructions, v) if base.instructions is not None else None,
65
+ )
lucida/_build.py ADDED
@@ -0,0 +1,104 @@
1
+ """Event assembly shared by the sync and asyncio clients: context merge, super properties, personless, geoip, before_send."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import threading
8
+ import uuid as uuidlib
9
+ from collections.abc import Callable, Mapping
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ from ._context import get_context
14
+ from ._core import LruSet, Props, WireEvent, make_event
15
+
16
+ IDENTITY_DEDUPE_SIZE = 1000
17
+
18
+ BeforeSend = Callable[[WireEvent], "WireEvent | None"]
19
+ OnError = Callable[[Exception, "list[WireEvent]"], None]
20
+
21
+
22
+ def notify(on_error: OnError | None, err: Exception, events: list[WireEvent]) -> None:
23
+ """Call the user's error callback; a raising callback must never break delivery."""
24
+ if on_error is None:
25
+ return
26
+ try:
27
+ on_error(err, events)
28
+ except Exception: # noqa: BLE001
29
+ pass
30
+
31
+
32
+ class EventBuilder:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ before_send: BeforeSend | None = None,
37
+ on_error: OnError | None = None,
38
+ disable_geoip: bool = False,
39
+ is_server: bool = False,
40
+ ) -> None:
41
+ self.before_send, self.on_error = before_send, on_error
42
+ self.disable_geoip = disable_geoip
43
+ self.super_props: dict[str, Any] = {}
44
+ self._is_server = is_server
45
+ self._identities = LruSet(IDENTITY_DEDUPE_SIZE)
46
+ self._identities_lock = threading.Lock()
47
+
48
+ def first_identity(self, *parts: Any) -> bool:
49
+ """True the first time this exact identify/group call (id + props) is seen in the process (bounded LRU)."""
50
+ key = hashlib.sha256(json.dumps(parts, sort_keys=True, default=str).encode()).hexdigest()
51
+ with self._identities_lock:
52
+ return self._identities.add(key)
53
+
54
+ def register(self, properties: Props) -> None:
55
+ """Client-wide default properties; per-event and context properties override them."""
56
+ self.super_props.update(properties)
57
+
58
+ def unregister(self, key: str) -> None:
59
+ self.super_props.pop(key, None)
60
+
61
+ def build(
62
+ self,
63
+ distinct_id: str | None,
64
+ event: str,
65
+ properties: Props | None = None,
66
+ *,
67
+ groups: Mapping[str, str] | None = None,
68
+ timestamp: datetime | str | None = None,
69
+ uuid: str | None = None,
70
+ set: Props | None = None, # noqa: A002
71
+ set_once: Props | None = None,
72
+ process_person_profile: bool = True,
73
+ disable_geoip: bool | None = None,
74
+ ) -> WireEvent | None:
75
+ """The wire event, or None when `before_send` dropped it. Explicit arguments beat the context."""
76
+ ctx = get_context()
77
+ distinct_id = distinct_id or (ctx.distinct_id if ctx else None)
78
+ if not distinct_id:
79
+ if process_person_profile:
80
+ raise ValueError("distinct_id is required (pass one, or use a context with identify_context)")
81
+ distinct_id = str(uuidlib.uuid4())
82
+ props: dict[str, Any] = dict(self.super_props)
83
+ if self._is_server:
84
+ props["$is_server"] = True
85
+ if ctx:
86
+ props.update(ctx.properties)
87
+ if ctx.session_id:
88
+ props["$session_id"] = ctx.session_id
89
+ props.update(properties or {})
90
+ if not process_person_profile:
91
+ props["$process_person_profile"] = False
92
+ set = set_once = None # noqa: A001 - ignored by the worker on personless events
93
+ if self.disable_geoip if disable_geoip is None else disable_geoip:
94
+ props["$geoip_disable"] = True
95
+ e = make_event(
96
+ distinct_id, event, props, groups=groups, timestamp=timestamp, uuid=uuid, set=set, set_once=set_once
97
+ )
98
+ if self.before_send is None:
99
+ return e
100
+ try:
101
+ return self.before_send(e)
102
+ except Exception as err: # noqa: BLE001
103
+ notify(self.on_error, err, [e])
104
+ return e # a raising hook keeps the original event
lucida/_context.py ADDED
@@ -0,0 +1,158 @@
1
+ """Ambient request context on contextvars (works across threads' own contexts and asyncio tasks).
2
+
3
+ Explicit capture arguments always win over context values. Context properties are merged under the
4
+ event's own properties; `distinct_id` and `$session_id` fill in only when the call gave none.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextvars
10
+ import functools
11
+ import inspect
12
+ import re
13
+ from collections.abc import Callable, Iterator, Mapping
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, TypeVar, overload
17
+
18
+ F = TypeVar("F", bound=Callable[..., Any])
19
+
20
+ DISTINCT_ID_HEADER = "X-Lucida-Distinct-Id"
21
+ SESSION_ID_HEADER = "X-Lucida-Session-Id"
22
+ _MAX_HEADER = 1000
23
+ _CTRL = re.compile(r"[\x00-\x1f\x7f]")
24
+
25
+
26
+ @dataclass
27
+ class Context:
28
+ distinct_id: str | None = None
29
+ session_id: str | None = None
30
+ properties: dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ _current: contextvars.ContextVar[Context | None] = contextvars.ContextVar("lucida_context", default=None)
34
+
35
+
36
+ def get_context() -> Context | None:
37
+ """The innermost active context, or None."""
38
+ return _current.get()
39
+
40
+
41
+ @contextmanager
42
+ def new_context(
43
+ *,
44
+ fresh: bool = False,
45
+ distinct_id: str | None = None,
46
+ session_id: str | None = None,
47
+ properties: Mapping[str, Any] | None = None,
48
+ ) -> Iterator[Context]:
49
+ """Open a context. Nested contexts inherit the parent's values unless `fresh=True`; changes never leak out."""
50
+ parent = None if fresh else _current.get()
51
+ ctx = Context(
52
+ distinct_id if distinct_id is not None else (parent.distinct_id if parent else None),
53
+ session_id if session_id is not None else (parent.session_id if parent else None),
54
+ {**(parent.properties if parent else {}), **(properties or {})},
55
+ )
56
+ token = _current.set(ctx)
57
+ try:
58
+ yield ctx
59
+ finally:
60
+ _current.reset(token)
61
+
62
+
63
+ @overload
64
+ def scoped(fn: F) -> F: ...
65
+ @overload
66
+ def scoped(*, fresh: bool = ...) -> Callable[[F], F]: ...
67
+ def scoped(fn: F | None = None, *, fresh: bool = False) -> Any:
68
+ """Decorator: run the function (sync or async) in its own context. `@scoped` or `@scoped(fresh=True)`."""
69
+
70
+ def wrap(f: F) -> F:
71
+ if inspect.iscoroutinefunction(f):
72
+
73
+ @functools.wraps(f)
74
+ async def aw(*a: Any, **k: Any) -> Any:
75
+ with new_context(fresh=fresh):
76
+ return await f(*a, **k)
77
+
78
+ return aw # type: ignore[return-value]
79
+
80
+ @functools.wraps(f)
81
+ def sw(*a: Any, **k: Any) -> Any:
82
+ with new_context(fresh=fresh):
83
+ return f(*a, **k)
84
+
85
+ return sw # type: ignore[return-value]
86
+
87
+ return wrap(fn) if fn is not None else wrap
88
+
89
+
90
+ def tag(key: str, value: Any) -> None:
91
+ """Add a property to the current context (no-op outside one)."""
92
+ if (c := _current.get()) is not None:
93
+ c.properties[key] = value
94
+
95
+
96
+ def identify_context(distinct_id: str) -> None:
97
+ if (c := _current.get()) is not None:
98
+ c.distinct_id = distinct_id
99
+
100
+
101
+ def set_context_session(session_id: str) -> None:
102
+ if (c := _current.get()) is not None:
103
+ c.session_id = session_id
104
+
105
+
106
+ def clean_header(v: str | None) -> str | None:
107
+ """Strip ASCII control chars, trim, cap at 1000 chars; empty -> None."""
108
+ if not v:
109
+ return None
110
+ return _CTRL.sub("", v).strip()[:_MAX_HEADER] or None
111
+
112
+
113
+ def read_tracing_headers(get: Callable[[str], str | None]) -> tuple[str | None, str | None]:
114
+ """(distinct_id, session_id) from the browser SDK's tracing headers. `get` looks a header up by name.
115
+
116
+ These headers are client-controlled: use them to join events to a browser session, never for authentication.
117
+ """
118
+ return clean_header(get(DISTINCT_ID_HEADER)), clean_header(get(SESSION_ID_HEADER))
119
+
120
+
121
+ def request_properties(
122
+ *, url: str | None, method: str | None, path: str | None, user_agent: str | None, ip: str | None
123
+ ) -> dict[str, Any]:
124
+ """The request props middleware stamps on the context (absent values are left out)."""
125
+ raw = {"$current_url": url, "$request_method": method, "$request_path": path, "$user_agent": user_agent, "$ip": ip}
126
+ return {k: v for k, v in raw.items() if v}
127
+
128
+
129
+ def client_ip(remote_addr: str | None, forwarded_for: str | None, trust_proxy: bool) -> str | None:
130
+ """First X-Forwarded-For entry only when `trust_proxy`; else the socket peer."""
131
+ if trust_proxy and forwarded_for:
132
+ first = forwarded_for.split(",")[0].strip()
133
+ if first:
134
+ return first
135
+ return remote_addr
136
+
137
+
138
+ def request_context_args(
139
+ get_header: Callable[[str], str | None],
140
+ *,
141
+ method: str | None,
142
+ url: str | None,
143
+ path: str | None,
144
+ remote_addr: str | None,
145
+ trust_proxy: bool = False,
146
+ capture_tracing_headers: bool = True,
147
+ extra: Mapping[str, Any] | None = None,
148
+ ) -> dict[str, Any]:
149
+ """Keyword arguments for `new_context(fresh=True, **args)`: tracing-header ids plus the stamped request props."""
150
+ did, sid = read_tracing_headers(get_header) if capture_tracing_headers else (None, None)
151
+ props = request_properties(
152
+ url=url,
153
+ method=method,
154
+ path=path,
155
+ user_agent=clean_header(get_header("User-Agent")),
156
+ ip=client_ip(remote_addr, get_header("X-Forwarded-For"), trust_proxy),
157
+ )
158
+ return {"distinct_id": did, "session_id": sid, "properties": {**props, **(extra or {})}}