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.
- flightdeck_sensor-0.3.0/PKG-INFO +95 -0
- flightdeck_sensor-0.3.0/README.md +56 -0
- flightdeck_sensor-0.3.0/flightdeck_sensor/__init__.py +492 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/context.py +12 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/session.py +28 -2
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/types.py +17 -1
- flightdeck_sensor-0.3.0/flightdeck_sensor/interceptor/anthropic.py +510 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/interceptor/base.py +39 -2
- flightdeck_sensor-0.3.0/flightdeck_sensor/interceptor/openai.py +751 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/anthropic.py +63 -7
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/openai.py +102 -2
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/protocol.py +25 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/transport/client.py +55 -32
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/pyproject.toml +20 -1
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_context.py +13 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_custom_directives.py +1 -1
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_interceptor.py +1 -1
- flightdeck_sensor-0.3.0/tests/unit/test_patch.py +573 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_prompt_capture.py +1 -1
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_providers.py +4 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_session.py +155 -1
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/test_transport.py +35 -12
- flightdeck_sensor-0.2.0/PKG-INFO +0 -37
- flightdeck_sensor-0.2.0/README.md +0 -3
- flightdeck_sensor-0.2.0/flightdeck_sensor/__init__.py +0 -405
- flightdeck_sensor-0.2.0/flightdeck_sensor/interceptor/anthropic.py +0 -128
- flightdeck_sensor-0.2.0/flightdeck_sensor/interceptor/openai.py +0 -182
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/.gitignore +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/Makefile +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/__init__.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/exceptions.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/policy.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/core/schemas.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/interceptor/__init__.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/providers/__init__.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/py.typed +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/transport/__init__.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/flightdeck_sensor/transport/retry.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/__init__.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/conftest.py +0 -0
- {flightdeck_sensor-0.2.0 → flightdeck_sensor-0.3.0}/tests/unit/__init__.py +0 -0
- {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(),
|