failproofai-sdk 0.0.1b1__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.
- failproofai_sdk/__init__.py +131 -0
- failproofai_sdk/_context.py +218 -0
- failproofai_sdk/_environment.py +78 -0
- failproofai_sdk/_events.py +752 -0
- failproofai_sdk/_resolver.py +118 -0
- failproofai_sdk/_runtime.py +21 -0
- failproofai_sdk/_schema.py +317 -0
- failproofai_sdk/_scopes.py +422 -0
- failproofai_sdk/_version.py +1 -0
- failproofai_sdk/_writer.py +750 -0
- failproofai_sdk/integrations/__init__.py +274 -0
- failproofai_sdk/integrations/_compat.py +247 -0
- failproofai_sdk/integrations/_core.py +1076 -0
- failproofai_sdk/integrations/crewai.py +1669 -0
- failproofai_sdk/integrations/langchain.py +2151 -0
- failproofai_sdk/integrations/llama_index.py +1588 -0
- failproofai_sdk/integrations/pydantic_ai.py +977 -0
- failproofai_sdk/py.typed +0 -0
- failproofai_sdk-0.0.1b1.dist-info/METADATA +548 -0
- failproofai_sdk-0.0.1b1.dist-info/RECORD +23 -0
- failproofai_sdk-0.0.1b1.dist-info/WHEEL +5 -0
- failproofai_sdk-0.0.1b1.dist-info/licenses/LICENSE +42 -0
- failproofai_sdk-0.0.1b1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Telemetry for AI agents: emit events, spool them, let the daemon ship them.
|
|
2
|
+
|
|
3
|
+
Three surfaces, in the order most people meet them:
|
|
4
|
+
|
|
5
|
+
* **Scopes** — `session()`, `agent()`, `tool_call()`. Context managers that bind
|
|
6
|
+
run identity and, for the latter two, bracket a run with its own events. Work
|
|
7
|
+
under `with` and `async with`.
|
|
8
|
+
* **Adapters** — `instrument()`. Auto-detects LangChain/LangGraph, CrewAI,
|
|
9
|
+
LlamaIndex and Pydantic AI in the process and wires them to the scopes above.
|
|
10
|
+
* **`event.*`** — the 15 event methods, for anything the adapters do not cover.
|
|
11
|
+
|
|
12
|
+
`session_id` and `agent_id` are optional on every event method: omitted, they
|
|
13
|
+
resolve from the enclosing scope. Nothing bound and nothing passed is an error,
|
|
14
|
+
never a silent drop — ingest skips an event with no session and answers 200.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from failproofai_sdk._version import __version__
|
|
20
|
+
from failproofai_sdk._environment import set_environment
|
|
21
|
+
from failproofai_sdk._resolver import set_base_dir
|
|
22
|
+
from failproofai_sdk._context import Identity, current, propagate
|
|
23
|
+
from failproofai_sdk._runtime import event
|
|
24
|
+
from failproofai_sdk._scopes import agent, session, tool_call
|
|
25
|
+
from failproofai_sdk._environment import _reject_comma
|
|
26
|
+
from failproofai_sdk._writer import _validated_interval
|
|
27
|
+
from failproofai_sdk import _runtime
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"configure",
|
|
32
|
+
"event",
|
|
33
|
+
"session",
|
|
34
|
+
"agent",
|
|
35
|
+
"tool_call",
|
|
36
|
+
"current",
|
|
37
|
+
"Identity",
|
|
38
|
+
"propagate",
|
|
39
|
+
"instrument",
|
|
40
|
+
"uninstrument",
|
|
41
|
+
"_writer",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def configure(
|
|
46
|
+
*,
|
|
47
|
+
base_dir=None,
|
|
48
|
+
flush_interval: float = 0.5,
|
|
49
|
+
environment: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Configure the SDK. Call once at startup before any event.* calls.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
base_dir: Override the spool root. Pass None to resolve it to
|
|
55
|
+
~/.failproofai/custom-agents (honouring $FAILPROOFAI_HOME,
|
|
56
|
+
which moves the umbrella but cannot take the spool outside
|
|
57
|
+
it — the `custom-agents` segment is always appended).
|
|
58
|
+
|
|
59
|
+
This is the ONLY way to spool anywhere else. No environment
|
|
60
|
+
variable redirects it; $AGENTEYE_HOME used to and no longer
|
|
61
|
+
does, because exporting it for the one component that still
|
|
62
|
+
reads it (the older `agenteye-collector`) moved this SDK's
|
|
63
|
+
spool as an unasked-for side effect.
|
|
64
|
+
|
|
65
|
+
The default moved here from ~/.agenteye. `failproofaid`
|
|
66
|
+
watches both roots, so on a host running it this only
|
|
67
|
+
changes which directory the files appear in, and batches
|
|
68
|
+
already spooled under the old root are still collected.
|
|
69
|
+
On a host running only `agenteye-collector`, give that
|
|
70
|
+
collector AGENTEYE_HOME=~/.failproofai/custom-agents so it
|
|
71
|
+
watches where this SDK writes — or pass base_dir here.
|
|
72
|
+
flush_interval: Seconds between flush cycles. Default 0.5 (500ms).
|
|
73
|
+
environment: Deployment environment label (e.g. "production", "staging").
|
|
74
|
+
Can also be set via the AGENTEYE_ENVIRONMENT env var.
|
|
75
|
+
Defaults to "dev" when neither is set.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: if `flush_interval` is not a finite number greater than zero.
|
|
79
|
+
Checked here, before anything is applied, so a rejected call leaves
|
|
80
|
+
the SDK exactly as it was rather than with a new base_dir and the old
|
|
81
|
+
interval.
|
|
82
|
+
"""
|
|
83
|
+
# BOTH validations before ANY application. `set_environment` raises on a
|
|
84
|
+
# comma, and it used to run last — so `configure(base_dir=..., environment=
|
|
85
|
+
# "prod,eu")` raised having already moved the spool and the flush interval,
|
|
86
|
+
# which is precisely the half-applied state the docstring above promises is
|
|
87
|
+
# impossible. A caller who wraps startup in `except ValueError` (a reasonable
|
|
88
|
+
# thing to do for a telemetry library that must not crash the agent) was left
|
|
89
|
+
# shipping from a directory they did not choose.
|
|
90
|
+
flush_interval = _validated_interval(flush_interval)
|
|
91
|
+
if environment:
|
|
92
|
+
_reject_comma(environment, "configure(environment=...)")
|
|
93
|
+
set_base_dir(base_dir)
|
|
94
|
+
_runtime.writer.set_flush_interval(flush_interval)
|
|
95
|
+
set_environment(environment)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def instrument(framework: str | None = None, **options: Any):
|
|
99
|
+
"""Install the framework adapters.
|
|
100
|
+
|
|
101
|
+
With no argument, auto-detects the frameworks already imported in this
|
|
102
|
+
process. Pass a name (`"langchain"`, `"crewai"`, `"llama_index"`,
|
|
103
|
+
`"pydantic_ai"`) to install exactly one.
|
|
104
|
+
|
|
105
|
+
The import is inside the function on purpose: `failproofai_sdk.integrations`
|
|
106
|
+
reaches for framework packages, and `import failproofai_sdk` is
|
|
107
|
+
contractually zero-dependency — a promise `tests/test_zero_dependencies.py`
|
|
108
|
+
enforces both by scanning the core modules and by launching a fresh
|
|
109
|
+
interpreter to prove no framework lands in `sys.modules`.
|
|
110
|
+
"""
|
|
111
|
+
from failproofai_sdk.integrations import instrument as _impl
|
|
112
|
+
|
|
113
|
+
return _impl(framework, **options)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def uninstrument(framework: str | None = None):
|
|
117
|
+
"""Reverse `instrument()`, restoring the original attributes.
|
|
118
|
+
|
|
119
|
+
Lazy-imported for the same reason as `instrument()`.
|
|
120
|
+
"""
|
|
121
|
+
from failproofai_sdk.integrations import uninstrument as _impl
|
|
122
|
+
|
|
123
|
+
return _impl(framework)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# MUST be last: any `import failproofai_sdk.<sub>` binds the *module* onto this
|
|
127
|
+
# package as `failproofai_sdk._writer`. Rebinding it here to the instance is what
|
|
128
|
+
# keeps the published `failproofai_sdk._writer.flush_now()` recipe working — and
|
|
129
|
+
# is why a test reaching for the MODULE has to go through
|
|
130
|
+
# `sys.modules["failproofai_sdk._writer"]`.
|
|
131
|
+
_writer = _runtime.writer
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Ambient run identity, carried on contextvars.
|
|
2
|
+
|
|
3
|
+
Before this module the SDK had no ambient session: every `event.*` call took
|
|
4
|
+
`session_id` and `agent_id` as required keyword arguments and nothing propagated
|
|
5
|
+
them. Threading both through every function that might emit an event is what makes
|
|
6
|
+
instrumentation sprawl into a diff nobody wants to review.
|
|
7
|
+
|
|
8
|
+
Two contextvars carry it instead. Read them through `current()`, or let
|
|
9
|
+
`failproofai_sdk._events` fall back to them when a caller omits the identity.
|
|
10
|
+
|
|
11
|
+
Why a tuple and not a list
|
|
12
|
+
--------------------------
|
|
13
|
+
`_AGENT_STACK` holds a **tuple**. A `ContextVar[list]` is shared *by reference*
|
|
14
|
+
across tasks and threads, so `.append()` in one task mutates the value every other
|
|
15
|
+
task sees — which is exactly the cross-run event mixing contextvars are here to
|
|
16
|
+
prevent, wearing a contextvars costume. It passes every single-threaded test.
|
|
17
|
+
Push is `set(stack + (aid,))`; pop is `reset(token)`.
|
|
18
|
+
|
|
19
|
+
There is deliberately no `_AGENT_ID` var: the top of the stack *is* the current
|
|
20
|
+
agent id, so the two cannot drift apart, and `parent_id` is `stack[-2]`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import contextvars
|
|
24
|
+
import functools
|
|
25
|
+
import logging
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# The agent_id used when events are emitted with a session bound but no agent
|
|
32
|
+
# scope. "main" is the convention the skill and the reference integration already
|
|
33
|
+
# teach, so an un-scoped event lands somewhere sensible rather than raising.
|
|
34
|
+
DEFAULT_AGENT_ID = "main"
|
|
35
|
+
|
|
36
|
+
_SESSION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
37
|
+
"failproofai_sdk_session_id", default=None
|
|
38
|
+
)
|
|
39
|
+
_AGENT_STACK: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
|
|
40
|
+
"failproofai_sdk_agent_stack", default=()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Identity:
|
|
46
|
+
"""The run identity in scope. Never None — check `session_id is None` instead."""
|
|
47
|
+
|
|
48
|
+
session_id: str | None
|
|
49
|
+
agent_id: str | None
|
|
50
|
+
parent_id: str | None
|
|
51
|
+
depth: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def current() -> Identity:
|
|
55
|
+
"""The identity bound to the current context.
|
|
56
|
+
|
|
57
|
+
`failproofai_sdk.current().session_id is None` means nothing is bound — either no
|
|
58
|
+
scope was entered, or this is a fresh thread that did not inherit one (see
|
|
59
|
+
`propagate`).
|
|
60
|
+
"""
|
|
61
|
+
stack = _AGENT_STACK.get()
|
|
62
|
+
return Identity(
|
|
63
|
+
session_id=_SESSION_ID.get(),
|
|
64
|
+
agent_id=stack[-1] if stack else None,
|
|
65
|
+
parent_id=stack[-2] if len(stack) >= 2 else None,
|
|
66
|
+
depth=len(stack),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def session_id() -> str | None:
|
|
71
|
+
"""The bound session id, or None. Hot path — allocates no Identity."""
|
|
72
|
+
return _SESSION_ID.get()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def agent_id() -> str:
|
|
76
|
+
"""The current agent id, falling back to DEFAULT_AGENT_ID."""
|
|
77
|
+
stack = _AGENT_STACK.get()
|
|
78
|
+
return stack[-1] if stack else DEFAULT_AGENT_ID
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parent_agent_id() -> str | None:
|
|
82
|
+
"""The enclosing agent id, or None at depth 0 or 1."""
|
|
83
|
+
stack = _AGENT_STACK.get()
|
|
84
|
+
return stack[-2] if len(stack) >= 2 else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bind_session(sid: str) -> contextvars.Token:
|
|
88
|
+
return _SESSION_ID.set(sid)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def push_agent(aid: str) -> contextvars.Token:
|
|
92
|
+
return _AGENT_STACK.set(_AGENT_STACK.get() + (aid,))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def reset(token: contextvars.Token | None) -> bool:
|
|
96
|
+
"""Restore a contextvar to its pre-`set` value, tolerating a cross-context token.
|
|
97
|
+
|
|
98
|
+
`ContextVar.reset()` raises `ValueError: Token was created in a different
|
|
99
|
+
Context` when the token was minted in another thread *or another asyncio task*.
|
|
100
|
+
That happens when a scope is entered in one task and exited in another — not
|
|
101
|
+
always the caller's bug, as this used to say: asyncio finalizes an abandoned
|
|
102
|
+
async generator from a task of its own, so an `agent()` scope around a
|
|
103
|
+
`yield` gets its `__aexit__` run somewhere the token is foreign through no
|
|
104
|
+
fault of the code that wrote it.
|
|
105
|
+
|
|
106
|
+
Returns True when the token-based restore succeeded. It is a `bool` rather
|
|
107
|
+
than `None` so the caller can repair the values directly instead of leaving
|
|
108
|
+
a closed span on someone's stack; swallowing the error was never enough on
|
|
109
|
+
its own.
|
|
110
|
+
"""
|
|
111
|
+
if token is None:
|
|
112
|
+
return True
|
|
113
|
+
try:
|
|
114
|
+
token.var.reset(token)
|
|
115
|
+
return True
|
|
116
|
+
except ValueError:
|
|
117
|
+
# WARNING, once, not a debug line. The consequence is not cosmetic: the
|
|
118
|
+
# frame this scope pushed stays bound in whatever context it was set in,
|
|
119
|
+
# so identity is wrong for everything that follows there. The caller
|
|
120
|
+
# cannot discover that any other way — there is no exception, and the
|
|
121
|
+
# events look plausible.
|
|
122
|
+
#
|
|
123
|
+
# It cannot always be repaired, either. When the set and the reset happen
|
|
124
|
+
# in different asyncio TASKS — the async-generator case in `agent()`'s
|
|
125
|
+
# docstring — `ContextVar.set` from this task cannot reach the context
|
|
126
|
+
# the value is bound in, so the caller has to change the code. Saying so
|
|
127
|
+
# once is the most this layer can do.
|
|
128
|
+
global _warned_cross_context
|
|
129
|
+
if not _warned_cross_context:
|
|
130
|
+
_warned_cross_context = True
|
|
131
|
+
logger.warning(
|
|
132
|
+
"failproofai_sdk: a scope was entered in one context and exited in "
|
|
133
|
+
"another, so its identity could not be unwound and later events in "
|
|
134
|
+
"the entering context may be attributed to it. The usual cause is an "
|
|
135
|
+
"`agent()`/`session()` scope spanning a `yield` in an async generator; "
|
|
136
|
+
"pass session_id=/agent_id= explicitly there instead.",
|
|
137
|
+
exc_info=True,
|
|
138
|
+
)
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def discard_agent(agent_id: str) -> None:
|
|
143
|
+
"""Remove ONE frame for `agent_id` from the current stack, innermost first.
|
|
144
|
+
|
|
145
|
+
The fallback for a token that cannot be reset. Removes a single occurrence
|
|
146
|
+
rather than every match, because the same `agent_id` may legitimately be on
|
|
147
|
+
the stack twice (a recursive agent), and dropping both would corrupt the
|
|
148
|
+
outer one to fix the inner.
|
|
149
|
+
"""
|
|
150
|
+
stack = _AGENT_STACK.get()
|
|
151
|
+
for i in range(len(stack) - 1, -1, -1):
|
|
152
|
+
if stack[i] == agent_id:
|
|
153
|
+
_AGENT_STACK.set(stack[:i] + stack[i + 1 :])
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def restore_session(session_id: str | None) -> None:
|
|
158
|
+
"""Put the session id back to a value captured before the scope was entered."""
|
|
159
|
+
_SESSION_ID.set(session_id)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
#: Deduplicates the cross-context warning above — it fires from a scope exit,
|
|
163
|
+
#: which in a streaming server is per request.
|
|
164
|
+
_warned_cross_context = False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
Snapshot = tuple[str | None, tuple[str, ...]]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def snapshot() -> Snapshot:
|
|
171
|
+
"""Capture the identity *values* currently bound."""
|
|
172
|
+
return (_SESSION_ID.get(), _AGENT_STACK.get())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def restore(snap: Snapshot) -> tuple[contextvars.Token, contextvars.Token]:
|
|
176
|
+
"""Bind a snapshot into the calling context."""
|
|
177
|
+
sid, stack = snap
|
|
178
|
+
return (_SESSION_ID.set(sid), _AGENT_STACK.set(stack))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def discard(tokens: tuple[contextvars.Token, contextvars.Token] | None) -> None:
|
|
182
|
+
if tokens is None:
|
|
183
|
+
return
|
|
184
|
+
for token in reversed(tokens):
|
|
185
|
+
reset(token)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def propagate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
189
|
+
"""Wrap `fn` so it runs with the identity bound *right now*.
|
|
190
|
+
|
|
191
|
+
pool.submit(failproofai_sdk.propagate(work), x)
|
|
192
|
+
pool.map(failproofai_sdk.propagate(work), items)
|
|
193
|
+
threading.Thread(target=failproofai_sdk.propagate(work)).start()
|
|
194
|
+
loop.run_in_executor(None, failproofai_sdk.propagate(work), x)
|
|
195
|
+
|
|
196
|
+
contextvars propagate into asyncio tasks automatically but **not into new
|
|
197
|
+
threads** — a thread starts with an empty context, so without this every event
|
|
198
|
+
a worker emits is dropped (or, since this change, raises TypeError).
|
|
199
|
+
|
|
200
|
+
This deliberately snapshots *values* rather than doing
|
|
201
|
+
`functools.partial(contextvars.copy_context().run, fn)`. A `Context` object
|
|
202
|
+
cannot be entered by two threads at once (`RuntimeError: cannot enter context:
|
|
203
|
+
... is already entered`), so the copy_context form crashes the caller's worker
|
|
204
|
+
on any reuse — `pool.map`, a retried submit. Mutations made inside `ctx.run`
|
|
205
|
+
also persist in that Context, so a reused one leaks the previous call's agent
|
|
206
|
+
stack into the next.
|
|
207
|
+
"""
|
|
208
|
+
snap = snapshot()
|
|
209
|
+
|
|
210
|
+
@functools.wraps(fn)
|
|
211
|
+
def _failproofai_propagated(*args: Any, **kwargs: Any) -> Any:
|
|
212
|
+
tokens = restore(snap)
|
|
213
|
+
try:
|
|
214
|
+
return fn(*args, **kwargs)
|
|
215
|
+
finally:
|
|
216
|
+
discard(tokens)
|
|
217
|
+
|
|
218
|
+
return _failproofai_propagated
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
_DEFAULT_ENVIRONMENT = "dev"
|
|
4
|
+
_environment: str | None = None
|
|
5
|
+
|
|
6
|
+
#: Set once the comma warning below has been emitted. `get_environment()` runs
|
|
7
|
+
#: from `to_dict()`, i.e. once per event on the caller's own thread, and the
|
|
8
|
+
#: warning had no once-flag at all — so an `AGENTEYE_ENVIRONMENT` with a comma
|
|
9
|
+
#: put one WARNING line into the host application's log for every event emitted,
|
|
10
|
+
#: for the life of the process. At the SDK's documented ceiling that is a
|
|
11
|
+
#: logging-driven throughput collapse in a library whose first constraint is not
|
|
12
|
+
#: to disrupt the host agent. The comment two lines below already promised
|
|
13
|
+
#: "Warn once"; this is what makes that true.
|
|
14
|
+
_warned_comma = False
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("failproofai_sdk")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _reject_comma(env: str, source: str) -> None:
|
|
20
|
+
"""A comma in `environment` makes ingest skip EVERY event carrying it.
|
|
21
|
+
|
|
22
|
+
The endpoint splits this field on commas to build its filter facets, so a
|
|
23
|
+
line whose `environment` contains one is discarded — the whole line, not the
|
|
24
|
+
field. It answers 200 with `{"accepted":0,"skipped":N}`, the daemon deletes
|
|
25
|
+
the delivered batch, and the run that produced it is simply never in the
|
|
26
|
+
dashboard: no exception here, nothing in the agent's output, and an empty
|
|
27
|
+
session list that looks exactly like an agent nobody ran.
|
|
28
|
+
|
|
29
|
+
`failproofaid` already refuses a comma in `collector.environment` for this
|
|
30
|
+
reason (`crates/fpai-collect/src/config.rs`). The SDK is the other writer of
|
|
31
|
+
the same field and did not, so `AGENTEYE_ENVIRONMENT="prod,eu"` — a wholly
|
|
32
|
+
reasonable thing to type — silently threw away everything the process
|
|
33
|
+
emitted.
|
|
34
|
+
"""
|
|
35
|
+
if "," in env:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"environment must not contain a comma (got {env!r} from {source}). "
|
|
38
|
+
"The ingest endpoint skips every event whose environment has one, so "
|
|
39
|
+
"this would silently discard all telemetry from this process. Use a "
|
|
40
|
+
"single label, e.g. 'prod-eu'."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_environment() -> str:
|
|
45
|
+
if _environment is not None:
|
|
46
|
+
return _environment
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
raw = os.environ.get("AGENTEYE_ENVIRONMENT")
|
|
50
|
+
if not raw:
|
|
51
|
+
return _DEFAULT_ENVIRONMENT
|
|
52
|
+
if "," in raw:
|
|
53
|
+
# Raising here would blow up inside `to_dict()` on an arbitrary event,
|
|
54
|
+
# far from the thing that set it, and take the caller's agent down with
|
|
55
|
+
# it — a telemetry library must not do that. Warn once and fall back to
|
|
56
|
+
# a label ingest will actually accept, so the events land under a
|
|
57
|
+
# visibly-wrong environment instead of vanishing.
|
|
58
|
+
global _warned_comma
|
|
59
|
+
if not _warned_comma:
|
|
60
|
+
_warned_comma = True
|
|
61
|
+
logger.warning(
|
|
62
|
+
"failproofai_sdk: AGENTEYE_ENVIRONMENT=%r contains a comma, which makes "
|
|
63
|
+
"the ingest endpoint skip every event carrying it. Falling back to %r. "
|
|
64
|
+
"Use a single label, e.g. 'prod-eu'.",
|
|
65
|
+
raw,
|
|
66
|
+
_DEFAULT_ENVIRONMENT,
|
|
67
|
+
)
|
|
68
|
+
return _DEFAULT_ENVIRONMENT
|
|
69
|
+
return raw
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_environment(env: str | None) -> None:
|
|
73
|
+
global _environment, _warned_comma
|
|
74
|
+
if env:
|
|
75
|
+
_reject_comma(env, "configure(environment=...)")
|
|
76
|
+
_environment = env if env else None
|
|
77
|
+
# A new label means the env var may be worth complaining about again.
|
|
78
|
+
_warned_comma = False
|