flightdeck-sensor 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (42) hide show
  1. flightdeck_sensor-0.3.0/PKG-INFO +95 -0
  2. flightdeck_sensor-0.3.0/README.md +56 -0
  3. flightdeck_sensor-0.3.0/flightdeck_sensor/__init__.py +492 -0
  4. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/context.py +12 -0
  5. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/session.py +28 -2
  6. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/types.py +17 -1
  7. flightdeck_sensor-0.3.0/flightdeck_sensor/interceptor/anthropic.py +510 -0
  8. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/interceptor/base.py +39 -2
  9. flightdeck_sensor-0.3.0/flightdeck_sensor/interceptor/openai.py +751 -0
  10. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/anthropic.py +63 -7
  11. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/openai.py +102 -2
  12. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/protocol.py +25 -0
  13. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/transport/client.py +55 -32
  14. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/pyproject.toml +20 -1
  15. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_context.py +13 -0
  16. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_custom_directives.py +1 -1
  17. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_interceptor.py +1 -1
  18. flightdeck_sensor-0.3.0/tests/unit/test_patch.py +573 -0
  19. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_prompt_capture.py +1 -1
  20. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_providers.py +4 -0
  21. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_session.py +155 -1
  22. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_transport.py +35 -12
  23. flightdeck_sensor-0.2.0/PKG-INFO +0 -37
  24. flightdeck_sensor-0.2.0/README.md +0 -3
  25. flightdeck_sensor-0.2.0/flightdeck_sensor/__init__.py +0 -405
  26. flightdeck_sensor-0.2.0/flightdeck_sensor/interceptor/anthropic.py +0 -128
  27. flightdeck_sensor-0.2.0/flightdeck_sensor/interceptor/openai.py +0 -182
  28. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/.gitignore +0 -0
  29. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/Makefile +0 -0
  30. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/__init__.py +0 -0
  31. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/exceptions.py +0 -0
  32. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/policy.py +0 -0
  33. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/schemas.py +0 -0
  34. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/interceptor/__init__.py +0 -0
  35. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/__init__.py +0 -0
  36. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/py.typed +0 -0
  37. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/transport/__init__.py +0 -0
  38. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/transport/retry.py +0 -0
  39. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/__init__.py +0 -0
  40. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/conftest.py +0 -0
  41. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/__init__.py +0 -0
  42. {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_policy.py +0 -0
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: flightdeck-sensor
3
+ Version: 0.3.0
4
+ Summary: In-process agent observability sensor for Flightdeck
5
+ License-Expression: Apache-2.0
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: pydantic>=2.0
18
+ Provides-Extra: anthropic
19
+ Requires-Dist: anthropic>=0.20; extra == 'anthropic'
20
+ Provides-Extra: dev
21
+ Requires-Dist: anthropic>=0.20; extra == 'dev'
22
+ Requires-Dist: crewai>=1.14; extra == 'dev'
23
+ Requires-Dist: httpx>=0.25; extra == 'dev'
24
+ Requires-Dist: langchain-anthropic>=0.1; extra == 'dev'
25
+ Requires-Dist: langchain-openai>=0.1; extra == 'dev'
26
+ Requires-Dist: llama-index-llms-anthropic>=0.1; extra == 'dev'
27
+ Requires-Dist: llama-index-llms-openai>=0.1; extra == 'dev'
28
+ Requires-Dist: mypy>=1.8; extra == 'dev'
29
+ Requires-Dist: openai>=1.0; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
32
+ Requires-Dist: pytest>=7.0; extra == 'dev'
33
+ Requires-Dist: respx>=0.20; extra == 'dev'
34
+ Requires-Dist: ruff>=0.3; extra == 'dev'
35
+ Provides-Extra: openai
36
+ Requires-Dist: openai>=1.0; extra == 'openai'
37
+ Requires-Dist: tiktoken>=0.5; extra == 'openai'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # flightdeck-sensor
41
+
42
+ In-process agent observability sensor for [Flightdeck](https://github.com/flightdeckhq/flightdeck).
43
+
44
+ ## Optional `session_id` hint (D094)
45
+
46
+ By default `init()` auto-generates a fresh UUID every time the process
47
+ starts. Orchestrators that re-run the same logical workflow (Temporal,
48
+ Airflow, cron) can instead pass a stable identifier; if the backend
49
+ already has a row for that session, the new execution is attached to it
50
+ and appears as a continuation of the prior run in the fleet view.
51
+
52
+ Supply the hint via either the `session_id=` kwarg or the
53
+ `FLIGHTDECK_SESSION_ID` environment variable. The env var takes
54
+ precedence.
55
+
56
+ The value MUST parse as a canonical UUID (any version) -- the
57
+ sessions table column is UUID-typed. If you pass a non-UUID the
58
+ sensor logs a warning and falls back to auto-generating one.
59
+ Orchestrators that use string identifiers (Temporal workflow_id,
60
+ Airflow dag_run_id) should hash the identifier into a deterministic
61
+ UUID with `uuid.uuid5`.
62
+
63
+ ### Temporal workflow example
64
+
65
+ ```python
66
+ import uuid
67
+ import flightdeck_sensor as fd
68
+ from temporalio import workflow
69
+
70
+ # Pick any fixed namespace UUID for your deployment. The same
71
+ # workflow_id + namespace always produces the same session UUID,
72
+ # so re-runs of the same workflow all map to the same sessions row.
73
+ FLIGHTDECK_NS = uuid.UUID("00000000-0000-0000-0000-000000000001")
74
+
75
+ @workflow.defn
76
+ class MyWorkflow:
77
+ @workflow.run
78
+ async def run(self, input):
79
+ ctx = workflow.info()
80
+ fd.init(
81
+ server="http://flightdeck.internal/ingest",
82
+ token="ftd_...",
83
+ session_id=str(uuid.uuid5(FLIGHTDECK_NS, ctx.workflow_id)),
84
+ )
85
+ # If this workflow_id has run before, the backend attaches
86
+ # this execution to the existing session automatically; the
87
+ # sensor logs INFO on the first response that confirms it.
88
+ ...
89
+ ```
90
+
91
+ The sensor logs a single WARNING at `init()` time whenever a custom
92
+ `session_id` is in play so the behaviour is visible in operational
93
+ logs, and an INFO line on the first response where the backend
94
+ confirms attachment. See DECISIONS.md D094 and ARCHITECTURE.md
95
+ ("Session attachment flow") for the full protocol.
@@ -0,0 +1,56 @@
1
+ # flightdeck-sensor
2
+
3
+ In-process agent observability sensor for [Flightdeck](https://github.com/flightdeckhq/flightdeck).
4
+
5
+ ## Optional `session_id` hint (D094)
6
+
7
+ By default `init()` auto-generates a fresh UUID every time the process
8
+ starts. Orchestrators that re-run the same logical workflow (Temporal,
9
+ Airflow, cron) can instead pass a stable identifier; if the backend
10
+ already has a row for that session, the new execution is attached to it
11
+ and appears as a continuation of the prior run in the fleet view.
12
+
13
+ Supply the hint via either the `session_id=` kwarg or the
14
+ `FLIGHTDECK_SESSION_ID` environment variable. The env var takes
15
+ precedence.
16
+
17
+ The value MUST parse as a canonical UUID (any version) -- the
18
+ sessions table column is UUID-typed. If you pass a non-UUID the
19
+ sensor logs a warning and falls back to auto-generating one.
20
+ Orchestrators that use string identifiers (Temporal workflow_id,
21
+ Airflow dag_run_id) should hash the identifier into a deterministic
22
+ UUID with `uuid.uuid5`.
23
+
24
+ ### Temporal workflow example
25
+
26
+ ```python
27
+ import uuid
28
+ import flightdeck_sensor as fd
29
+ from temporalio import workflow
30
+
31
+ # Pick any fixed namespace UUID for your deployment. The same
32
+ # workflow_id + namespace always produces the same session UUID,
33
+ # so re-runs of the same workflow all map to the same sessions row.
34
+ FLIGHTDECK_NS = uuid.UUID("00000000-0000-0000-0000-000000000001")
35
+
36
+ @workflow.defn
37
+ class MyWorkflow:
38
+ @workflow.run
39
+ async def run(self, input):
40
+ ctx = workflow.info()
41
+ fd.init(
42
+ server="http://flightdeck.internal/ingest",
43
+ token="ftd_...",
44
+ session_id=str(uuid.uuid5(FLIGHTDECK_NS, ctx.workflow_id)),
45
+ )
46
+ # If this workflow_id has run before, the backend attaches
47
+ # this execution to the existing session automatically; the
48
+ # sensor logs INFO on the first response that confirms it.
49
+ ...
50
+ ```
51
+
52
+ The sensor logs a single WARNING at `init()` time whenever a custom
53
+ `session_id` is in play so the behaviour is visible in operational
54
+ logs, and an INFO line on the first response where the backend
55
+ confirms attachment. See DECISIONS.md D094 and ARCHITECTURE.md
56
+ ("Session attachment flow") for the full protocol.
@@ -0,0 +1,492 @@
1
+ """flightdeck-sensor: in-process agent observability for Flightdeck.
2
+
3
+ Two-line integration::
4
+
5
+ import flightdeck_sensor
6
+ flightdeck_sensor.init(server="http://localhost:4000/ingest", token="tok_dev")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import logging
13
+ import os
14
+ import threading
15
+ import uuid
16
+ from typing import Any, Callable
17
+
18
+ from flightdeck_sensor.core.context import collect as _collect_context
19
+ from flightdeck_sensor.core.exceptions import (
20
+ BudgetExceededError,
21
+ ConfigurationError,
22
+ DirectiveError,
23
+ )
24
+ from flightdeck_sensor.core.session import Session
25
+ from flightdeck_sensor.core.types import (
26
+ DirectiveParameter,
27
+ DirectiveRegistration,
28
+ SensorConfig,
29
+ StatusResponse,
30
+ )
31
+ from flightdeck_sensor.interceptor.anthropic import (
32
+ SensorAnthropic,
33
+ _OrigAnthropic,
34
+ _OrigAsyncAnthropic,
35
+ patch_anthropic_classes,
36
+ unpatch_anthropic_classes,
37
+ )
38
+ from flightdeck_sensor.interceptor.openai import (
39
+ SensorOpenAI,
40
+ _OrigAsyncOpenAI,
41
+ _OrigOpenAI,
42
+ patch_openai_classes,
43
+ unpatch_openai_classes,
44
+ )
45
+ from flightdeck_sensor.transport.client import ControlPlaneClient
46
+
47
+ Parameter = DirectiveParameter
48
+
49
+ __all__ = [
50
+ "init",
51
+ "wrap",
52
+ "patch",
53
+ "unpatch",
54
+ "get_status",
55
+ "teardown",
56
+ "directive",
57
+ "Parameter",
58
+ "BudgetExceededError",
59
+ "ConfigurationError",
60
+ "DirectiveError",
61
+ ]
62
+
63
+ _log = logging.getLogger("flightdeck_sensor")
64
+
65
+ # Global state -- protected by _lock.
66
+ # v1 design: process-wide singleton. Multi-session-in-one-process is a
67
+ # v2 concern; users who need isolated sessions should run separate
68
+ # processes (one sensor per process). See DECISIONS.md D091.
69
+ _lock = threading.Lock()
70
+ _patch_lock = threading.Lock()
71
+ _session: Session | None = None
72
+ _client: ControlPlaneClient | None = None
73
+
74
+ # Custom directive registry -- populated by @directive decorator
75
+ _directive_registry: dict[str, DirectiveRegistration] = {}
76
+
77
+
78
+ # ------------------------------------------------------------------
79
+ # Custom directive registration
80
+ # ------------------------------------------------------------------
81
+
82
+
83
+ def _compute_fingerprint(
84
+ name: str, description: str, parameters: list[DirectiveParameter]
85
+ ) -> str:
86
+ """Compute a deterministic SHA-256 fingerprint for a directive schema."""
87
+ import base64
88
+ import hashlib
89
+ import json
90
+
91
+ payload = json.dumps(
92
+ {
93
+ "name": name,
94
+ "description": description,
95
+ "parameters": [
96
+ {
97
+ "name": p.name,
98
+ "type": p.type,
99
+ "description": p.description,
100
+ "options": p.options,
101
+ "required": p.required,
102
+ "default": p.default,
103
+ }
104
+ for p in parameters
105
+ ],
106
+ },
107
+ sort_keys=True,
108
+ )
109
+ return base64.b64encode(hashlib.sha256(payload.encode()).digest()).decode()
110
+
111
+
112
+ def directive(
113
+ name: str,
114
+ description: str = "",
115
+ parameters: list[Parameter] | None = None,
116
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
117
+ """Decorator to register a function as a custom directive handler.
118
+
119
+ Example::
120
+
121
+ @flightdeck_sensor.directive("pause", description="Pause the agent")
122
+ def handle_pause(ctx, duration=30):
123
+ time.sleep(duration)
124
+
125
+ .. warning:: The ``parameters`` schema you declare here is used to
126
+ compute the directive fingerprint and to render the parameter
127
+ form on the dashboard. **It is NOT enforced at execution
128
+ time.** When the dashboard issues a directive, the
129
+ ``parameters`` dict in the request body is passed straight
130
+ through to your handler as ``**kwargs`` after only shape-level
131
+ validation (``directive_name: str``, ``fingerprint: str``,
132
+ ``parameters: dict``). The handler is responsible for
133
+ validating its own inputs -- if you declare ``value: int`` in
134
+ the schema, you should still defensively check ``isinstance(
135
+ value, int)`` inside the handler. Type mismatches that crash
136
+ the handler are caught by the runtime and logged, but bad
137
+ input data may produce surprising side effects before the
138
+ crash. Phase 4.5 audit Hat 4 finding.
139
+ """
140
+
141
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
142
+ params = parameters or []
143
+ fp = _compute_fingerprint(name, description, params)
144
+ _directive_registry[name] = DirectiveRegistration(
145
+ name=name,
146
+ description=description,
147
+ parameters=params,
148
+ fingerprint=fp,
149
+ handler=fn,
150
+ )
151
+ return fn
152
+
153
+ return decorator
154
+
155
+
156
+ # ------------------------------------------------------------------
157
+ # Public API
158
+ # ------------------------------------------------------------------
159
+
160
+
161
+ def init(
162
+ server: str,
163
+ token: str,
164
+ api_url: str | None = None,
165
+ capture_prompts: bool = False,
166
+ quiet: bool = False,
167
+ limit: int | None = None,
168
+ warn_at: float = 0.8,
169
+ session_id: str | None = None,
170
+ ) -> None:
171
+ """Initialize the sensor and start the session.
172
+
173
+ ``token`` here is a Flightdeck **access token** (an ``ftd_...``
174
+ opaque string minted via ``POST /v1/access-tokens``, or the
175
+ literal ``tok_dev`` seed when the server is running with
176
+ ``ENVIRONMENT=dev``). It is NOT an LLM token count -- the
177
+ platform also tracks input/output token totals on sessions, but
178
+ those live under ``tokens_input`` / ``tokens_output`` fields
179
+ and never flow through this parameter. The kwarg name (and the
180
+ ``FLIGHTDECK_TOKEN`` env var) deliberately stayed as ``token``
181
+ after the D096 rename so existing integrations don't break.
182
+
183
+ ``api_url`` is the base URL for control-plane calls (directive
184
+ registration, directive sync, policy prefetch). When *None*,
185
+ derived from *server* by replacing ``/ingest`` with ``/api``.
186
+ Override via ``FLIGHTDECK_API_URL`` env var.
187
+
188
+ ``limit`` sets a local WARN-only token threshold. Never blocks. Never
189
+ degrades. Most restrictive threshold wins when both local and server
190
+ policies are active. See DECISIONS.md D035.
191
+
192
+ ``session_id`` is an optional caller-supplied identifier. When
193
+ provided (or when ``FLIGHTDECK_SESSION_ID`` is set, which takes
194
+ precedence over the kwarg in line with ``FLIGHTDECK_SERVER`` /
195
+ ``AGENT_FLAVOR``), the sensor uses the caller's value verbatim
196
+ instead of generating a UUID. If a session with that ID already
197
+ exists in the control plane, the backend attaches this execution
198
+ to the prior session; the sensor logs INFO on the first response
199
+ that confirms attachment. Primary use case: orchestrators
200
+ (Temporal workflows, Airflow DAGs) that re-run the same logical
201
+ workflow and want a single correlatable session in the fleet view.
202
+ See DECISIONS.md D094.
203
+
204
+ Reads from environment (overrides parameters):
205
+
206
+ - ``FLIGHTDECK_API_URL`` -- control-plane base URL (overrides *api_url*)
207
+ - ``FLIGHTDECK_SESSION_ID`` -- session id hint (overrides *session_id*)
208
+ - ``AGENT_FLAVOR`` -- persistent identity (default: ``"unknown"``)
209
+ - ``AGENT_TYPE`` -- ``"autonomous"``, ``"supervised"``, or ``"batch"``
210
+ - ``FLIGHTDECK_UNAVAILABLE_POLICY`` -- ``"continue"`` or ``"halt"``
211
+ - ``FLIGHTDECK_CAPTURE_PROMPTS`` -- ``"true"`` to enable
212
+ """
213
+ global _session, _client
214
+
215
+ with _lock:
216
+ if _session is not None:
217
+ if not quiet:
218
+ _log.warning("flightdeck_sensor.init() called twice; ignoring")
219
+ return
220
+
221
+ resolved_server = os.environ.get("FLIGHTDECK_SERVER", server)
222
+ resolved_token = os.environ.get("FLIGHTDECK_TOKEN", token)
223
+ if not resolved_server:
224
+ raise ConfigurationError("server URL is required")
225
+ if not resolved_token:
226
+ raise ConfigurationError("token is required")
227
+
228
+ resolved_api_url = os.environ.get("FLIGHTDECK_API_URL") or api_url
229
+ if not resolved_api_url:
230
+ resolved_api_url = resolved_server.rstrip("/").replace(
231
+ "/ingest", "/api"
232
+ )
233
+
234
+ capture = _env_bool("FLIGHTDECK_CAPTURE_PROMPTS", capture_prompts)
235
+
236
+ # session_id resolution follows the same env-wins pattern as
237
+ # FLIGHTDECK_SERVER / AGENT_FLAVOR: env var overrides kwarg,
238
+ # and a falsy env var falls through to the kwarg. An empty
239
+ # string from either source is treated as "not provided" so a
240
+ # misconfigured shell (FLIGHTDECK_SESSION_ID="") still auto-
241
+ # generates a UUID rather than posting a session_start with a
242
+ # blank session_id that the ingestion API rejects.
243
+ resolved_session_id = (
244
+ os.environ.get("FLIGHTDECK_SESSION_ID") or session_id or None
245
+ )
246
+ if resolved_session_id and not _is_valid_uuid(resolved_session_id):
247
+ # The sessions table column is UUID-typed; accepting a
248
+ # non-UUID here would trip Postgres at worker time and
249
+ # drop every event for this agent. Warn loudly and fall
250
+ # back to auto-generation so the agent still boots. The
251
+ # common source of this is orchestrators (Temporal
252
+ # workflow_id, Airflow dag_run_id) that are strings, not
253
+ # UUIDs -- callers need to hash them into a UUID before
254
+ # passing, e.g. uuid.uuid5(NAMESPACE_URL, workflow_id).
255
+ _log.warning(
256
+ "Custom session_id '%s' is not a valid UUID. A random "
257
+ "session ID will be generated instead.",
258
+ resolved_session_id,
259
+ )
260
+ resolved_session_id = None
261
+ if resolved_session_id:
262
+ _log.warning(
263
+ "Custom session_id provided: '%s'. This ID will be used "
264
+ "as-is and will not be auto-generated. If a session with "
265
+ "this ID already exists, the backend will attach this "
266
+ "agent to it.",
267
+ resolved_session_id,
268
+ )
269
+
270
+ config_kwargs: dict[str, Any] = {
271
+ "server": resolved_server,
272
+ "token": resolved_token,
273
+ "api_url": resolved_api_url,
274
+ "capture_prompts": capture,
275
+ "unavailable_policy": os.environ.get(
276
+ "FLIGHTDECK_UNAVAILABLE_POLICY", "continue"
277
+ ),
278
+ "agent_flavor": os.environ.get("AGENT_FLAVOR", "unknown"),
279
+ "agent_type": os.environ.get("AGENT_TYPE", "autonomous"),
280
+ "quiet": quiet,
281
+ "limit": limit,
282
+ "warn_at": warn_at,
283
+ }
284
+ # Only pass session_id when the caller asked for a specific
285
+ # value; otherwise let SensorConfig's default_factory generate
286
+ # a fresh UUID as before. Passing session_id=None would
287
+ # overwrite the factory output with None.
288
+ if resolved_session_id:
289
+ config_kwargs["session_id"] = resolved_session_id
290
+ config = SensorConfig(**config_kwargs)
291
+
292
+ _client = ControlPlaneClient(
293
+ server=config.server,
294
+ token=config.token,
295
+ api_url=config.api_url,
296
+ unavailable_policy=config.unavailable_policy,
297
+ )
298
+ _session = Session(config=config, client=_client)
299
+
300
+ # Best-effort runtime context collection. Never raises -- if
301
+ # any collector fails the agent continues with no context
302
+ # attached. Set on the session BEFORE start() so the
303
+ # session_start event payload includes it.
304
+ runtime_ctx: dict[str, Any] = {}
305
+ with contextlib.suppress(Exception):
306
+ runtime_ctx = _collect_context()
307
+ _session.set_context(runtime_ctx)
308
+
309
+ _session.start()
310
+
311
+
312
+ def wrap(client: Any, quiet: bool = False) -> Any:
313
+ """Wrap an Anthropic or OpenAI client for interception.
314
+
315
+ ``init()`` must be called first.
316
+
317
+ If :func:`patch` has already been called, the client's class has
318
+ a class-level ``messages`` / ``chat`` descriptor installed and the
319
+ client's resource access is already intercepted -- in that case
320
+ ``wrap()`` is a no-op and returns the client unchanged. This
321
+ avoids double-wrapping.
322
+ """
323
+ session = _require_session("wrap")
324
+
325
+ # Detect Anthropic client
326
+ if _is_anthropic(client):
327
+ # If the class is already patched, the descriptor handles
328
+ # interception transparently and wrapping again would produce
329
+ # a SensorMessages-of-SensorMessages on first .messages access.
330
+ if hasattr(type(client), "_flightdeck_patched"):
331
+ return client
332
+ return SensorAnthropic(client, session)
333
+
334
+ # Detect OpenAI client
335
+ if _is_openai(client):
336
+ if hasattr(type(client), "_flightdeck_patched"):
337
+ return client
338
+ return SensorOpenAI(client, session)
339
+
340
+ if not quiet:
341
+ _log.warning(
342
+ "wrap(): unrecognised client type %s; returning unwrapped",
343
+ type(client).__name__,
344
+ )
345
+ return client
346
+
347
+
348
+ def patch(
349
+ quiet: bool = False,
350
+ providers: list[str] | None = None,
351
+ ) -> None:
352
+ """Class-level patch the Anthropic and OpenAI SDKs.
353
+
354
+ After ``patch()``, every instance of ``anthropic.Anthropic``,
355
+ ``anthropic.AsyncAnthropic``, ``openai.OpenAI``, and
356
+ ``openai.AsyncOpenAI`` -- including instances constructed
357
+ transparently by frameworks such as ``langchain-anthropic``,
358
+ ``langchain-openai``, ``llama-index-llms-anthropic``, and
359
+ ``llama-index-llms-openai`` -- will have its first ``.messages``
360
+ or ``.chat`` access return a flightdeck-managed proxy that posts
361
+ pre/post events for every LLM call.
362
+
363
+ The patch mutates each class object in place by replacing the
364
+ ``messages``/``chat`` ``cached_property`` descriptor with a custom
365
+ descriptor and tagging the class with a ``_flightdeck_patched``
366
+ sentinel attribute. ``isinstance(x, anthropic.Anthropic)`` and
367
+ captured references like ``from anthropic import Anthropic``
368
+ continue to work correctly because the class object's identity is
369
+ preserved.
370
+
371
+ **Idempotent**: calling ``patch()`` twice is a no-op on the second
372
+ call -- the descriptor is only installed if the class does not
373
+ already carry the ``_flightdeck_patched`` sentinel.
374
+
375
+ **Limitation**: instances of these classes that were constructed
376
+ BEFORE ``patch()`` was called and that already accessed
377
+ ``.messages`` / ``.chat`` once will have the unwrapped resource
378
+ cached in their ``__dict__`` and will not be intercepted. New
379
+ instances and new accesses on existing instances ARE intercepted.
380
+
381
+ ``init()`` must be called first.
382
+
383
+ Args:
384
+ providers: list of provider names to patch. Default patches all
385
+ available providers (``["anthropic", "openai"]``).
386
+ """
387
+ _require_session("patch")
388
+ targets = providers or ["anthropic", "openai"]
389
+
390
+ with _patch_lock:
391
+ if "anthropic" in targets:
392
+ patch_anthropic_classes(quiet=quiet)
393
+ if "openai" in targets:
394
+ patch_openai_classes(quiet=quiet)
395
+
396
+
397
+ def unpatch() -> None:
398
+ """Reverse all class-level patches applied by :func:`patch`.
399
+
400
+ Idempotent: safe to call without a preceding ``patch()``. Restores
401
+ the original ``cached_property`` descriptors and removes the
402
+ ``_flightdeck_patched`` sentinels.
403
+
404
+ Instances that have already accessed ``.messages`` / ``.chat``
405
+ after ``patch()`` was called keep the wrapped version cached in
406
+ their ``__dict__`` until the instance is garbage collected. This
407
+ is a known limitation -- documented in
408
+ :func:`unpatch_anthropic_classes` and
409
+ :func:`unpatch_openai_classes`.
410
+ """
411
+ with _patch_lock:
412
+ unpatch_anthropic_classes()
413
+ unpatch_openai_classes()
414
+
415
+
416
+ def get_status() -> StatusResponse:
417
+ """Return a snapshot of the current session status."""
418
+ session = _require_session("get_status")
419
+ return session.get_status()
420
+
421
+
422
+ def teardown() -> None:
423
+ """End the session, close transport, and reset global state."""
424
+ global _session, _client
425
+
426
+ with _lock:
427
+ if _session is not None:
428
+ _session.end()
429
+ _session = None
430
+ if _client is not None:
431
+ _client.close()
432
+ _client = None
433
+
434
+ unpatch()
435
+
436
+
437
+ # ------------------------------------------------------------------
438
+ # Internals
439
+ # ------------------------------------------------------------------
440
+
441
+
442
+ def _require_session(caller: str) -> Session:
443
+ with _lock:
444
+ if _session is None:
445
+ raise ConfigurationError(
446
+ f"{caller}() called before init(). Call flightdeck_sensor.init() first."
447
+ )
448
+ return _session
449
+
450
+
451
+ def _is_valid_uuid(value: str) -> bool:
452
+ """Return True when *value* parses as a canonical UUID string.
453
+
454
+ The sessions table uses Postgres ``UUID`` which accepts any valid
455
+ UUID (any version), so the check is deliberately permissive about
456
+ version -- only the string shape matters. ``uuid.UUID(value)``
457
+ already validates hex chars, hyphen placement, and length; any
458
+ failure raises ``ValueError`` which we swallow and return False.
459
+ """
460
+ try:
461
+ uuid.UUID(value)
462
+ return True
463
+ except (ValueError, AttributeError, TypeError):
464
+ return False
465
+
466
+
467
+ def _env_bool(key: str, default: bool) -> bool:
468
+ raw = os.environ.get(key, "")
469
+ if raw.lower() in ("true", "1", "yes"):
470
+ return True
471
+ if raw.lower() in ("false", "0", "no"):
472
+ return False
473
+ return default
474
+
475
+
476
+ def _is_anthropic(client: Any) -> bool:
477
+ """Detect an Anthropic / AsyncAnthropic client via captured references.
478
+
479
+ Uses the original class references captured at interceptor-module
480
+ import time so that ``isinstance`` checks survive ``patch()``
481
+ mutating the module attributes.
482
+ """
483
+ if _OrigAnthropic is None or _OrigAsyncAnthropic is None:
484
+ return False
485
+ return isinstance(client, (_OrigAnthropic, _OrigAsyncAnthropic))
486
+
487
+
488
+ def _is_openai(client: Any) -> bool:
489
+ """Detect an OpenAI / AsyncOpenAI client via captured references."""
490
+ if _OrigOpenAI is None or _OrigAsyncOpenAI is None:
491
+ return False
492
+ return isinstance(client, (_OrigOpenAI, _OrigAsyncOpenAI))
@@ -267,6 +267,17 @@ class LangChainClassifier(BaseClassifier):
267
267
  module = "langchain"
268
268
 
269
269
 
270
+ class LangGraphClassifier(BaseClassifier):
271
+ # LangGraph builds on LangChain and routes its LLM calls through
272
+ # the same ChatAnthropic / ChatOpenAI abstractions, so the
273
+ # existing patch() already intercepts LangGraph-driven calls.
274
+ # This classifier exists so the session_start context accurately
275
+ # reports LangGraph vs bare LangChain in the dashboard CONTEXT
276
+ # panel and for framework analytics. See ARCHITECTURE.md.
277
+ name = "langgraph"
278
+ module = "langgraph"
279
+
280
+
270
281
  class LlamaIndexClassifier(BaseClassifier):
271
282
  name = "llama_index"
272
283
  module = "llama_index"
@@ -301,6 +312,7 @@ class FrameworkCollector(BaseCollector):
301
312
  CLASSIFIERS: list[BaseClassifier] = [
302
313
  CrewAIClassifier(),
303
314
  LangChainClassifier(),
315
+ LangGraphClassifier(),
304
316
  LlamaIndexClassifier(),
305
317
  AutoGenClassifier(),
306
318
  HaystackClassifier(),