clue-fastapi-sdk 0.0.1__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.
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from clue_python_sdk_core import (
|
|
2
|
+
ClueCommand,
|
|
3
|
+
ClueIdentify,
|
|
4
|
+
ClueLogout,
|
|
5
|
+
ClueSetAccount,
|
|
6
|
+
ClueStateTransition,
|
|
7
|
+
ClueTrack,
|
|
8
|
+
ClueToolCall,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .fastapi import (
|
|
12
|
+
ClueFastApiMiddleware,
|
|
13
|
+
clue_init_fastapi,
|
|
14
|
+
instrument_celery,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ClueFastApiMiddleware",
|
|
19
|
+
"ClueCommand",
|
|
20
|
+
"ClueIdentify",
|
|
21
|
+
"ClueLogout",
|
|
22
|
+
"ClueSetAccount",
|
|
23
|
+
"ClueStateTransition",
|
|
24
|
+
"ClueTrack",
|
|
25
|
+
"ClueToolCall",
|
|
26
|
+
"clue_init_fastapi",
|
|
27
|
+
"instrument_celery",
|
|
28
|
+
]
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from urllib.parse import parse_qs
|
|
9
|
+
|
|
10
|
+
from clue_python_sdk_core import (
|
|
11
|
+
CluePythonBootstrapConfig,
|
|
12
|
+
CluePythonSettings,
|
|
13
|
+
JsonValue,
|
|
14
|
+
build_backend_error_event,
|
|
15
|
+
build_request_failed_event,
|
|
16
|
+
build_request_finished_event,
|
|
17
|
+
build_request_started_event,
|
|
18
|
+
configure_opentelemetry_environment,
|
|
19
|
+
create_client,
|
|
20
|
+
create_request_context,
|
|
21
|
+
default_traces_endpoint_from_ingest_endpoint,
|
|
22
|
+
enqueue_client_events,
|
|
23
|
+
initialize_opentelemetry,
|
|
24
|
+
instrument_celery_signals,
|
|
25
|
+
instrument_sqlalchemy_orm,
|
|
26
|
+
instrument_requests,
|
|
27
|
+
load_settings,
|
|
28
|
+
reset_current_state,
|
|
29
|
+
set_current_state,
|
|
30
|
+
to_path_template,
|
|
31
|
+
)
|
|
32
|
+
from clue_python_sdk_core.runtime import (
|
|
33
|
+
annotate_current_otel_span,
|
|
34
|
+
build_settings,
|
|
35
|
+
configure_settings,
|
|
36
|
+
emit_sdk_initialized,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
ASGIApp = Callable[[dict[str, object], Callable[[], Awaitable[dict[str, object]]], Callable[[dict[str, object]], Awaitable[None]]], Awaitable[None]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClueFastApiMiddleware:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
app: ASGIApp,
|
|
46
|
+
*,
|
|
47
|
+
settings_loader: Callable[[], CluePythonSettings] = load_settings,
|
|
48
|
+
timer: Callable[[], float] = time.perf_counter,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._app = app
|
|
51
|
+
self._settings_loader = settings_loader
|
|
52
|
+
self._timer = timer
|
|
53
|
+
|
|
54
|
+
def __call__(
|
|
55
|
+
self,
|
|
56
|
+
scope: dict[str, object],
|
|
57
|
+
receive: Callable[[], Awaitable[dict[str, object]]],
|
|
58
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
59
|
+
) -> Awaitable[None]:
|
|
60
|
+
return self._dispatch(scope, receive, send)
|
|
61
|
+
|
|
62
|
+
async def _dispatch(
|
|
63
|
+
self,
|
|
64
|
+
scope: dict[str, object],
|
|
65
|
+
receive: Callable[[], Awaitable[dict[str, object]]],
|
|
66
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
67
|
+
) -> None:
|
|
68
|
+
if scope.get("type") != "http":
|
|
69
|
+
await self._app(scope, receive, send)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
settings = self._settings_loader()
|
|
73
|
+
if not settings.enabled:
|
|
74
|
+
await self._app(scope, receive, send)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
client = create_client(settings)
|
|
78
|
+
if client is None:
|
|
79
|
+
await self._app(scope, receive, send)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
request_messages, request_body = await _buffer_request_messages(receive)
|
|
83
|
+
request = _FastApiRequest(scope, request_body)
|
|
84
|
+
context = create_request_context(settings=settings, request=request)
|
|
85
|
+
annotate_current_otel_span(
|
|
86
|
+
{
|
|
87
|
+
"clue.request_id": context.get("request_id"),
|
|
88
|
+
"clue.request_span_id": context.get("request_span_id"),
|
|
89
|
+
"clue.interaction_id": context.get("interaction_id"),
|
|
90
|
+
"clue.anonymous_id": context.get("anonymous_id"),
|
|
91
|
+
"clue.user_id": context.get("user_id"),
|
|
92
|
+
"clue.account_id": context.get("account_id"),
|
|
93
|
+
"clue.session_id": context.get("session_id"),
|
|
94
|
+
"clue.tab_id": context.get("tab_id"),
|
|
95
|
+
},
|
|
96
|
+
expected_kind="SERVER",
|
|
97
|
+
)
|
|
98
|
+
handler_name = _resolve_handler_name(scope)
|
|
99
|
+
client.add_event(
|
|
100
|
+
build_request_started_event(
|
|
101
|
+
request=request,
|
|
102
|
+
context=context,
|
|
103
|
+
handler_name=handler_name,
|
|
104
|
+
allowed_value_paths=settings.allowed_value_paths,
|
|
105
|
+
denied_keys=settings.denied_keys,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
started_at = self._timer()
|
|
110
|
+
response_capture = _ResponseCapture()
|
|
111
|
+
client_token, context_token = set_current_state(client=client, context=context)
|
|
112
|
+
replay_receive = _build_replay_receive(request_messages)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await self._app(scope, replay_receive, response_capture.capture(send))
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
client.add_event(
|
|
118
|
+
build_backend_error_event(
|
|
119
|
+
context=context,
|
|
120
|
+
handler_name=handler_name,
|
|
121
|
+
failure_type="unhandled_exception",
|
|
122
|
+
error_class=exc.__class__.__name__,
|
|
123
|
+
message=str(exc),
|
|
124
|
+
exception=exc,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
client.add_event(
|
|
128
|
+
build_request_failed_event(
|
|
129
|
+
request=request,
|
|
130
|
+
context=context,
|
|
131
|
+
handler_name=handler_name,
|
|
132
|
+
response=_ErrorResponse(),
|
|
133
|
+
status_code=500,
|
|
134
|
+
duration_ms=int((self._timer() - started_at) * 1000),
|
|
135
|
+
allowed_value_paths=settings.allowed_value_paths,
|
|
136
|
+
denied_keys=settings.denied_keys,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
enqueue_client_events(settings, client)
|
|
140
|
+
raise
|
|
141
|
+
finally:
|
|
142
|
+
reset_current_state(client_token, context_token)
|
|
143
|
+
|
|
144
|
+
client.add_event(
|
|
145
|
+
build_request_finished_event(
|
|
146
|
+
request=request,
|
|
147
|
+
response=response_capture.to_response(),
|
|
148
|
+
context=context,
|
|
149
|
+
handler_name=handler_name,
|
|
150
|
+
duration_ms=int((self._timer() - started_at) * 1000),
|
|
151
|
+
allowed_value_paths=settings.allowed_value_paths,
|
|
152
|
+
denied_keys=settings.denied_keys,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
enqueue_client_events(settings, client)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def clue_init_fastapi(
|
|
159
|
+
app: object,
|
|
160
|
+
*,
|
|
161
|
+
project_key: str,
|
|
162
|
+
environment: str,
|
|
163
|
+
api_key: str | None = None,
|
|
164
|
+
service_name: str = "python-service",
|
|
165
|
+
producer_id: str | None = None,
|
|
166
|
+
service_key: str | None = None,
|
|
167
|
+
instrument_outbound_requests: bool = True,
|
|
168
|
+
instrument_celery_if_available: bool = True,
|
|
169
|
+
instrument_orm_if_available: bool = False,
|
|
170
|
+
state_fields: Sequence[str] = (),
|
|
171
|
+
) -> bool:
|
|
172
|
+
add_middleware = getattr(app, "add_middleware", None)
|
|
173
|
+
if not callable(add_middleware):
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
settings = build_settings(
|
|
177
|
+
project_key=project_key,
|
|
178
|
+
environment=environment,
|
|
179
|
+
api_key=api_key,
|
|
180
|
+
service_name=service_name,
|
|
181
|
+
service_key=service_key,
|
|
182
|
+
producer_id=producer_id,
|
|
183
|
+
capture_outbound_requests=instrument_outbound_requests,
|
|
184
|
+
capture_celery=instrument_celery_if_available,
|
|
185
|
+
capture_orm=instrument_orm_if_available,
|
|
186
|
+
state_fields=state_fields,
|
|
187
|
+
)
|
|
188
|
+
configure_settings(settings)
|
|
189
|
+
emit_sdk_initialized(settings)
|
|
190
|
+
bootstrap_config = CluePythonBootstrapConfig(
|
|
191
|
+
service_name=service_name,
|
|
192
|
+
producer_id=settings.producer_id,
|
|
193
|
+
project_key=project_key,
|
|
194
|
+
environment=environment,
|
|
195
|
+
deployment_environment=environment,
|
|
196
|
+
service_type="api",
|
|
197
|
+
traces_endpoint=(
|
|
198
|
+
os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
|
199
|
+
or os.environ.get("CLUE_OTLP_TRACES_ENDPOINT")
|
|
200
|
+
or default_traces_endpoint_from_ingest_endpoint(settings.endpoint or "")
|
|
201
|
+
),
|
|
202
|
+
api_key=api_key,
|
|
203
|
+
service_version=(
|
|
204
|
+
os.environ.get("OTEL_SERVICE_VERSION")
|
|
205
|
+
or os.environ.get("CLUE_SERVICE_VERSION")
|
|
206
|
+
),
|
|
207
|
+
service_namespace=(
|
|
208
|
+
os.environ.get("OTEL_SERVICE_NAMESPACE")
|
|
209
|
+
or os.environ.get("CLUE_SERVICE_NAMESPACE")
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
configure_opentelemetry_environment(bootstrap_config)
|
|
213
|
+
initialize_opentelemetry(bootstrap_config)
|
|
214
|
+
add_middleware(ClueFastApiMiddleware)
|
|
215
|
+
updated = True
|
|
216
|
+
|
|
217
|
+
if instrument_outbound_requests and instrument_requests():
|
|
218
|
+
updated = True
|
|
219
|
+
|
|
220
|
+
if instrument_celery_if_available and instrument_celery():
|
|
221
|
+
updated = True
|
|
222
|
+
|
|
223
|
+
if instrument_orm_if_available and instrument_sqlalchemy_orm():
|
|
224
|
+
updated = True
|
|
225
|
+
|
|
226
|
+
return updated
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def instrument_celery() -> bool:
|
|
230
|
+
try:
|
|
231
|
+
from celery import signals
|
|
232
|
+
except ImportError:
|
|
233
|
+
try:
|
|
234
|
+
signals = importlib.import_module("celery.signals")
|
|
235
|
+
except ImportError:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
return instrument_celery_signals(
|
|
239
|
+
task_prerun=signals.task_prerun,
|
|
240
|
+
task_postrun=signals.task_postrun,
|
|
241
|
+
task_failure=signals.task_failure,
|
|
242
|
+
before_task_publish=getattr(signals, "before_task_publish", None),
|
|
243
|
+
after_task_publish=getattr(signals, "after_task_publish", None),
|
|
244
|
+
task_retry=getattr(signals, "task_retry", None),
|
|
245
|
+
task_revoked=getattr(signals, "task_revoked", None),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _FastApiRequest:
|
|
250
|
+
def __init__(self, scope: dict[str, object], body: bytes) -> None:
|
|
251
|
+
self.method = str(scope.get("method", "GET"))
|
|
252
|
+
self.path = str(scope.get("path", "/"))
|
|
253
|
+
self.body = body
|
|
254
|
+
self.scheme = str(scope.get("scheme", "http"))
|
|
255
|
+
self.headers = _decode_headers(scope.get("headers"))
|
|
256
|
+
self.GET = _decode_query(scope.get("query_string"))
|
|
257
|
+
self.COOKIES = _parse_cookies(self.headers.get("cookie"))
|
|
258
|
+
self.user = scope.get(
|
|
259
|
+
"user",
|
|
260
|
+
SimpleNamespace(is_authenticated=False),
|
|
261
|
+
)
|
|
262
|
+
self.resolver_match = SimpleNamespace(route=_resolve_path_template(scope, self.path))
|
|
263
|
+
|
|
264
|
+
def get_full_path(self) -> str:
|
|
265
|
+
if not self.GET:
|
|
266
|
+
return self.path
|
|
267
|
+
query_pairs: list[str] = []
|
|
268
|
+
for key, value in self.GET.items():
|
|
269
|
+
if isinstance(value, list):
|
|
270
|
+
for entry in value:
|
|
271
|
+
query_pairs.append(f"{key}={entry}")
|
|
272
|
+
else:
|
|
273
|
+
query_pairs.append(f"{key}={value}")
|
|
274
|
+
return f"{self.path}?{'&'.join(query_pairs)}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _ResponseCapture:
|
|
278
|
+
def __init__(self) -> None:
|
|
279
|
+
self.status_code = 200
|
|
280
|
+
self.headers: dict[str, str] = {}
|
|
281
|
+
self.body_parts: list[bytes] = []
|
|
282
|
+
|
|
283
|
+
def capture(
|
|
284
|
+
self,
|
|
285
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
286
|
+
) -> Callable[[dict[str, object]], Awaitable[None]]:
|
|
287
|
+
async def _send(message: dict[str, object]) -> None:
|
|
288
|
+
if message.get("type") == "http.response.start":
|
|
289
|
+
self.status_code = int(message.get("status", 200))
|
|
290
|
+
self.headers = _decode_headers(message.get("headers"))
|
|
291
|
+
elif message.get("type") == "http.response.body":
|
|
292
|
+
body = message.get("body", b"")
|
|
293
|
+
if isinstance(body, bytes):
|
|
294
|
+
self.body_parts.append(body)
|
|
295
|
+
await send(message)
|
|
296
|
+
|
|
297
|
+
return _send
|
|
298
|
+
|
|
299
|
+
def to_response(self) -> object:
|
|
300
|
+
return SimpleNamespace(
|
|
301
|
+
status_code=self.status_code,
|
|
302
|
+
body=b"".join(self.body_parts),
|
|
303
|
+
headers=self.headers,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _ErrorResponse:
|
|
308
|
+
status_code = 500
|
|
309
|
+
body = b""
|
|
310
|
+
headers: dict[str, JsonValue] = {}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _buffer_request_messages(
|
|
314
|
+
receive: Callable[[], Awaitable[dict[str, object]]],
|
|
315
|
+
) -> tuple[list[dict[str, object]], bytes]:
|
|
316
|
+
messages: list[dict[str, object]] = []
|
|
317
|
+
body_parts: list[bytes] = []
|
|
318
|
+
while True:
|
|
319
|
+
message = await receive()
|
|
320
|
+
messages.append(message)
|
|
321
|
+
if message.get("type") != "http.request":
|
|
322
|
+
break
|
|
323
|
+
body = message.get("body", b"")
|
|
324
|
+
if isinstance(body, bytes):
|
|
325
|
+
body_parts.append(body)
|
|
326
|
+
if not message.get("more_body", False):
|
|
327
|
+
break
|
|
328
|
+
return messages, b"".join(body_parts)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _build_replay_receive(
|
|
332
|
+
messages: list[dict[str, object]],
|
|
333
|
+
) -> Callable[[], Awaitable[dict[str, object]]]:
|
|
334
|
+
queue = list(messages)
|
|
335
|
+
|
|
336
|
+
async def _receive() -> dict[str, object]:
|
|
337
|
+
if queue:
|
|
338
|
+
return queue.pop(0)
|
|
339
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
340
|
+
|
|
341
|
+
return _receive
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _decode_headers(raw_headers: object) -> dict[str, str]:
|
|
345
|
+
if isinstance(raw_headers, dict):
|
|
346
|
+
return {str(key).lower(): str(value) for key, value in raw_headers.items()}
|
|
347
|
+
if not isinstance(raw_headers, list):
|
|
348
|
+
return {}
|
|
349
|
+
result: dict[str, str] = {}
|
|
350
|
+
for entry in raw_headers:
|
|
351
|
+
if not isinstance(entry, tuple) or len(entry) != 2:
|
|
352
|
+
continue
|
|
353
|
+
key, value = entry
|
|
354
|
+
key_text = key.decode("latin-1") if isinstance(key, bytes) else str(key)
|
|
355
|
+
value_text = value.decode("latin-1") if isinstance(value, bytes) else str(value)
|
|
356
|
+
result[key_text.lower()] = value_text
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _decode_query(raw_query: object) -> dict[str, JsonValue]:
|
|
361
|
+
if isinstance(raw_query, bytes):
|
|
362
|
+
parsed = parse_qs(raw_query.decode("utf-8"), keep_blank_values=True)
|
|
363
|
+
return {
|
|
364
|
+
key: values[0] if len(values) == 1 else values
|
|
365
|
+
for key, values in parsed.items()
|
|
366
|
+
}
|
|
367
|
+
return {}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _parse_cookies(raw_cookie: str | None) -> dict[str, str]:
|
|
371
|
+
if not raw_cookie:
|
|
372
|
+
return {}
|
|
373
|
+
result: dict[str, str] = {}
|
|
374
|
+
for entry in raw_cookie.split(";"):
|
|
375
|
+
key, _, value = entry.strip().partition("=")
|
|
376
|
+
if key:
|
|
377
|
+
result[key] = value
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resolve_handler_name(scope: dict[str, object]) -> str | None:
|
|
382
|
+
route = scope.get("route")
|
|
383
|
+
route_name = getattr(route, "name", None)
|
|
384
|
+
if isinstance(route_name, str) and route_name.strip():
|
|
385
|
+
return route_name
|
|
386
|
+
endpoint = scope.get("endpoint")
|
|
387
|
+
name = getattr(endpoint, "__name__", None)
|
|
388
|
+
if isinstance(name, str) and name.strip():
|
|
389
|
+
return name
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _resolve_path_template(scope: dict[str, object], path: str) -> str:
|
|
394
|
+
route = scope.get("route")
|
|
395
|
+
path_format = getattr(route, "path_format", None)
|
|
396
|
+
if isinstance(path_format, str) and path_format.strip():
|
|
397
|
+
return path_format
|
|
398
|
+
return to_path_template(path)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: clue-fastapi-sdk
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Clue FastAPI SDK with built-in backend runtime
|
|
5
|
+
Home-page: UNKNOWN
|
|
6
|
+
License: UNKNOWN
|
|
7
|
+
Platform: UNKNOWN
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: clue-python-sdk-core (==0.0.1)
|
|
10
|
+
|
|
11
|
+
UNKNOWN
|
|
12
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
clue_fastapi_sdk/__init__.py,sha256=GnVawxmRWyQVeUn9J6xvi34qarHcxyz1fiYKct3z6gs,442
|
|
2
|
+
clue_fastapi_sdk/fastapi.py,sha256=GePPWM2lge_BS1wi88z3lTBnxR2APfH9udr3Dsfq0JY,11432
|
|
3
|
+
clue_fastapi_sdk-0.0.1.dist-info/METADATA,sha256=X9gG_E8JwcGAmsIP-Eom4JuXlYXykI7YbkELIpIdbW0,249
|
|
4
|
+
clue_fastapi_sdk-0.0.1.dist-info/WHEEL,sha256=ewwEueio1C2XeHTvT17n8dZUJgOvyCWCt0WVNLClP9o,92
|
|
5
|
+
clue_fastapi_sdk-0.0.1.dist-info/top_level.txt,sha256=ll7k_BH8VqU3IR0Xt00fWy5WYVezb9qSgIv--pWvMf8,17
|
|
6
|
+
clue_fastapi_sdk-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clue_fastapi_sdk
|