letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721104533__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.
- letta/__init__.py +1 -1
- letta/agent.py +27 -11
- letta/agents/helpers.py +1 -1
- letta/agents/letta_agent.py +518 -322
- letta/agents/letta_agent_batch.py +1 -2
- letta/agents/voice_agent.py +15 -17
- letta/client/client.py +3 -3
- letta/constants.py +5 -0
- letta/embeddings.py +0 -2
- letta/errors.py +8 -0
- letta/functions/function_sets/base.py +3 -3
- letta/functions/helpers.py +2 -3
- letta/groups/sleeptime_multi_agent.py +0 -1
- letta/helpers/composio_helpers.py +2 -2
- letta/helpers/converters.py +1 -1
- letta/helpers/pinecone_utils.py +8 -0
- letta/helpers/tool_rule_solver.py +13 -18
- letta/llm_api/aws_bedrock.py +16 -2
- letta/llm_api/cohere.py +1 -1
- letta/llm_api/openai_client.py +1 -1
- letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
- letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
- letta/local_llm/utils.py +1 -2
- letta/orm/agent.py +3 -3
- letta/orm/block.py +4 -4
- letta/orm/files_agents.py +0 -1
- letta/orm/identity.py +2 -0
- letta/orm/mcp_server.py +0 -2
- letta/orm/message.py +140 -14
- letta/orm/organization.py +5 -5
- letta/orm/passage.py +4 -4
- letta/orm/source.py +1 -1
- letta/orm/sqlalchemy_base.py +61 -39
- letta/orm/step.py +2 -0
- letta/otel/db_pool_monitoring.py +308 -0
- letta/otel/metric_registry.py +94 -1
- letta/otel/sqlalchemy_instrumentation.py +548 -0
- letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
- letta/otel/tracing.py +37 -1
- letta/schemas/agent.py +0 -3
- letta/schemas/agent_file.py +283 -0
- letta/schemas/block.py +0 -3
- letta/schemas/file.py +28 -26
- letta/schemas/letta_message.py +15 -4
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +31 -26
- letta/schemas/openai/chat_completion_response.py +0 -1
- letta/schemas/providers.py +20 -0
- letta/schemas/source.py +11 -13
- letta/schemas/step.py +12 -0
- letta/schemas/tool.py +0 -4
- letta/serialize_schemas/marshmallow_agent.py +14 -1
- letta/serialize_schemas/marshmallow_block.py +23 -1
- letta/serialize_schemas/marshmallow_message.py +1 -3
- letta/serialize_schemas/marshmallow_tool.py +23 -1
- letta/server/db.py +110 -6
- letta/server/rest_api/app.py +85 -73
- letta/server/rest_api/routers/v1/agents.py +68 -53
- letta/server/rest_api/routers/v1/blocks.py +2 -2
- letta/server/rest_api/routers/v1/jobs.py +3 -0
- letta/server/rest_api/routers/v1/organizations.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +18 -2
- letta/server/rest_api/routers/v1/tools.py +11 -12
- letta/server/rest_api/routers/v1/users.py +1 -1
- letta/server/rest_api/streaming_response.py +13 -5
- letta/server/rest_api/utils.py +8 -25
- letta/server/server.py +11 -4
- letta/server/ws_api/server.py +2 -2
- letta/services/agent_file_manager.py +616 -0
- letta/services/agent_manager.py +133 -46
- letta/services/block_manager.py +38 -17
- letta/services/file_manager.py +106 -21
- letta/services/file_processor/file_processor.py +93 -0
- letta/services/files_agents_manager.py +28 -0
- letta/services/group_manager.py +4 -5
- letta/services/helpers/agent_manager_helper.py +57 -9
- letta/services/identity_manager.py +22 -0
- letta/services/job_manager.py +210 -91
- letta/services/llm_batch_manager.py +9 -6
- letta/services/mcp/stdio_client.py +1 -2
- letta/services/mcp_manager.py +0 -1
- letta/services/message_manager.py +49 -26
- letta/services/passage_manager.py +0 -1
- letta/services/provider_manager.py +1 -1
- letta/services/source_manager.py +114 -5
- letta/services/step_manager.py +36 -4
- letta/services/telemetry_manager.py +9 -2
- letta/services/tool_executor/builtin_tool_executor.py +5 -1
- letta/services/tool_executor/core_tool_executor.py +3 -3
- letta/services/tool_manager.py +95 -20
- letta/services/user_manager.py +4 -12
- letta/settings.py +23 -6
- letta/system.py +1 -1
- letta/utils.py +26 -2
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/METADATA +3 -2
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/RECORD +99 -94
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,548 @@
|
|
1
|
+
import asyncio
|
2
|
+
import threading
|
3
|
+
import traceback
|
4
|
+
from contextlib import contextmanager
|
5
|
+
from functools import wraps
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
7
|
+
|
8
|
+
from opentelemetry import trace
|
9
|
+
from opentelemetry.trace import Status, StatusCode
|
10
|
+
from sqlalchemy import Engine, event
|
11
|
+
from sqlalchemy.orm import Session
|
12
|
+
from sqlalchemy.orm.loading import load_on_ident, load_on_pk_identity
|
13
|
+
from sqlalchemy.orm.strategies import ImmediateLoader, JoinedLoader, LazyLoader, SelectInLoader, SubqueryLoader
|
14
|
+
|
15
|
+
_config = {
|
16
|
+
"enabled": True,
|
17
|
+
"sql_truncate_length": 1000,
|
18
|
+
"monitor_joined_loading": True,
|
19
|
+
"log_instrumentation_errors": True,
|
20
|
+
}
|
21
|
+
|
22
|
+
_instrumentation_state = {
|
23
|
+
"engine_listeners": [],
|
24
|
+
"session_listeners": [],
|
25
|
+
"original_methods": {},
|
26
|
+
"active": False,
|
27
|
+
}
|
28
|
+
|
29
|
+
_context = threading.local()
|
30
|
+
|
31
|
+
|
32
|
+
def _get_tracer():
|
33
|
+
"""Get the OpenTelemetry tracer for SQLAlchemy instrumentation."""
|
34
|
+
return trace.get_tracer("sqlalchemy_sync_instrumentation", "1.0.0")
|
35
|
+
|
36
|
+
|
37
|
+
def _is_event_loop_running() -> bool:
|
38
|
+
"""Check if an asyncio event loop is running in the current thread."""
|
39
|
+
try:
|
40
|
+
loop = asyncio.get_running_loop()
|
41
|
+
return loop.is_running()
|
42
|
+
except RuntimeError:
|
43
|
+
return False
|
44
|
+
|
45
|
+
|
46
|
+
def _is_main_thread() -> bool:
|
47
|
+
"""Check if we're running on the main thread."""
|
48
|
+
return threading.current_thread() is threading.main_thread()
|
49
|
+
|
50
|
+
|
51
|
+
def _truncate_sql(sql: str, max_length: int = 1000) -> str:
|
52
|
+
"""Truncate SQL statement to specified length."""
|
53
|
+
if len(sql) <= max_length:
|
54
|
+
return sql
|
55
|
+
return sql[: max_length - 3] + "..."
|
56
|
+
|
57
|
+
|
58
|
+
def _create_sync_db_span(
|
59
|
+
operation_type: str,
|
60
|
+
sql_statement: Optional[str] = None,
|
61
|
+
loader_type: Optional[str] = None,
|
62
|
+
relationship_key: Optional[str] = None,
|
63
|
+
is_joined: bool = False,
|
64
|
+
additional_attrs: Optional[Dict[str, Any]] = None,
|
65
|
+
) -> Any:
|
66
|
+
"""
|
67
|
+
Create an OpenTelemetry span for a synchronous database operation.
|
68
|
+
|
69
|
+
Args:
|
70
|
+
operation_type: Type of database operation
|
71
|
+
sql_statement: SQL statement being executed
|
72
|
+
loader_type: Type of SQLAlchemy loader (selectin, joined, lazy, etc.)
|
73
|
+
relationship_key: Name of relationship attribute if applicable
|
74
|
+
is_joined: Whether this is from joined loading
|
75
|
+
additional_attrs: Additional attributes to add to the span
|
76
|
+
|
77
|
+
Returns:
|
78
|
+
OpenTelemetry span
|
79
|
+
"""
|
80
|
+
if not _config["enabled"]:
|
81
|
+
return None
|
82
|
+
|
83
|
+
# Only create spans for potentially problematic operations
|
84
|
+
if not _is_event_loop_running():
|
85
|
+
return None
|
86
|
+
|
87
|
+
tracer = _get_tracer()
|
88
|
+
span = tracer.start_span("db_operation")
|
89
|
+
|
90
|
+
# Set core attributes
|
91
|
+
span.set_attribute("db.operation.type", operation_type)
|
92
|
+
|
93
|
+
# SQL statement
|
94
|
+
if sql_statement:
|
95
|
+
span.set_attribute("db.statement", _truncate_sql(sql_statement, _config["sql_truncate_length"]))
|
96
|
+
|
97
|
+
# Loader information
|
98
|
+
if loader_type:
|
99
|
+
span.set_attribute("sqlalchemy.loader.type", loader_type)
|
100
|
+
span.set_attribute("sqlalchemy.loader.is_joined", is_joined)
|
101
|
+
|
102
|
+
# Relationship information
|
103
|
+
if relationship_key:
|
104
|
+
span.set_attribute("sqlalchemy.relationship.key", relationship_key)
|
105
|
+
|
106
|
+
# Additional attributes
|
107
|
+
if additional_attrs:
|
108
|
+
for key, value in additional_attrs.items():
|
109
|
+
span.set_attribute(key, value)
|
110
|
+
|
111
|
+
return span
|
112
|
+
|
113
|
+
|
114
|
+
def _instrument_engine_events(engine: Engine) -> None:
|
115
|
+
"""Instrument SQLAlchemy engine events to detect sync operations."""
|
116
|
+
|
117
|
+
# Check if this is an AsyncEngine and get its sync_engine if it is
|
118
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
119
|
+
|
120
|
+
if isinstance(engine, AsyncEngine):
|
121
|
+
engine = engine.sync_engine
|
122
|
+
|
123
|
+
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
124
|
+
"""Track cursor execution start."""
|
125
|
+
if not _config["enabled"]:
|
126
|
+
return
|
127
|
+
|
128
|
+
# Store context for the after event
|
129
|
+
context._sync_instrumentation_span = _create_sync_db_span(
|
130
|
+
operation_type="cursor_execute",
|
131
|
+
sql_statement=statement,
|
132
|
+
additional_attrs={
|
133
|
+
"db.executemany": executemany,
|
134
|
+
"db.connection.info": str(conn.info),
|
135
|
+
},
|
136
|
+
)
|
137
|
+
|
138
|
+
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
139
|
+
"""Track cursor execution completion."""
|
140
|
+
if not _config["enabled"]:
|
141
|
+
return
|
142
|
+
|
143
|
+
span = getattr(context, "_sync_instrumentation_span", None)
|
144
|
+
if span:
|
145
|
+
span.set_status(Status(StatusCode.OK))
|
146
|
+
span.end()
|
147
|
+
context._sync_instrumentation_span = None
|
148
|
+
|
149
|
+
def handle_cursor_error(conn, cursor, statement, parameters, context, executemany):
|
150
|
+
"""Handle cursor execution errors."""
|
151
|
+
if not _config["enabled"]:
|
152
|
+
return
|
153
|
+
|
154
|
+
span = getattr(context, "_sync_instrumentation_span", None)
|
155
|
+
if span:
|
156
|
+
span.set_status(Status(StatusCode.ERROR, "Database operation failed"))
|
157
|
+
span.end()
|
158
|
+
context._sync_instrumentation_span = None
|
159
|
+
|
160
|
+
# Register engine events
|
161
|
+
event.listen(engine, "before_cursor_execute", before_cursor_execute)
|
162
|
+
event.listen(engine, "after_cursor_execute", after_cursor_execute)
|
163
|
+
event.listen(engine, "handle_error", handle_cursor_error)
|
164
|
+
|
165
|
+
# Store listeners for cleanup
|
166
|
+
_instrumentation_state["engine_listeners"].extend(
|
167
|
+
[
|
168
|
+
(engine, "before_cursor_execute", before_cursor_execute),
|
169
|
+
(engine, "after_cursor_execute", after_cursor_execute),
|
170
|
+
(engine, "handle_error", handle_cursor_error),
|
171
|
+
]
|
172
|
+
)
|
173
|
+
|
174
|
+
|
175
|
+
def _instrument_loader_strategies() -> None:
|
176
|
+
"""Instrument SQLAlchemy loader strategies to detect lazy loading."""
|
177
|
+
|
178
|
+
def create_loader_wrapper(loader_class: type, loader_type: str, is_joined: bool = False):
|
179
|
+
"""Create a wrapper for loader strategy methods."""
|
180
|
+
|
181
|
+
def wrapper(original_method: Callable):
|
182
|
+
@wraps(original_method)
|
183
|
+
def instrumented_method(self, *args, **kwargs):
|
184
|
+
# Extract relationship information if available
|
185
|
+
relationship_key = getattr(self, "key", None)
|
186
|
+
if hasattr(self, "parent_property"):
|
187
|
+
relationship_key = getattr(self.parent_property, "key", relationship_key)
|
188
|
+
|
189
|
+
span = _create_sync_db_span(
|
190
|
+
operation_type="loader_strategy",
|
191
|
+
loader_type=loader_type,
|
192
|
+
relationship_key=relationship_key,
|
193
|
+
is_joined=is_joined,
|
194
|
+
additional_attrs={
|
195
|
+
"sqlalchemy.loader.class": loader_class.__name__,
|
196
|
+
"sqlalchemy.loader.method": original_method.__name__,
|
197
|
+
},
|
198
|
+
)
|
199
|
+
|
200
|
+
try:
|
201
|
+
result = original_method(self, *args, **kwargs)
|
202
|
+
if span:
|
203
|
+
span.set_status(Status(StatusCode.OK))
|
204
|
+
return result
|
205
|
+
except Exception as e:
|
206
|
+
if span:
|
207
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
208
|
+
raise
|
209
|
+
finally:
|
210
|
+
if span:
|
211
|
+
span.end()
|
212
|
+
|
213
|
+
return instrumented_method
|
214
|
+
|
215
|
+
return wrapper
|
216
|
+
|
217
|
+
# Instrument different loader strategies
|
218
|
+
loaders_to_instrument = [
|
219
|
+
(SelectInLoader, "selectin", False),
|
220
|
+
(JoinedLoader, "joined", True),
|
221
|
+
(LazyLoader, "lazy", False),
|
222
|
+
(SubqueryLoader, "subquery", False),
|
223
|
+
(ImmediateLoader, "immediate", False),
|
224
|
+
]
|
225
|
+
|
226
|
+
for loader_class, loader_type, is_joined in loaders_to_instrument:
|
227
|
+
# Skip if monitoring joined loading is disabled
|
228
|
+
if is_joined and not _config["monitor_joined_loading"]:
|
229
|
+
continue
|
230
|
+
|
231
|
+
wrapper = create_loader_wrapper(loader_class, loader_type, is_joined)
|
232
|
+
|
233
|
+
# Instrument key methods
|
234
|
+
methods_to_instrument = ["_load_for_path", "load_for_path"]
|
235
|
+
|
236
|
+
for method_name in methods_to_instrument:
|
237
|
+
if hasattr(loader_class, method_name):
|
238
|
+
original_method = getattr(loader_class, method_name)
|
239
|
+
key = f"{loader_class.__name__}.{method_name}"
|
240
|
+
|
241
|
+
# Store original method for cleanup
|
242
|
+
_instrumentation_state["original_methods"][key] = original_method
|
243
|
+
|
244
|
+
# Apply wrapper
|
245
|
+
setattr(loader_class, method_name, wrapper(original_method))
|
246
|
+
|
247
|
+
# Instrument additional joined loading specific methods
|
248
|
+
if _config["monitor_joined_loading"]:
|
249
|
+
joined_methods = [
|
250
|
+
(JoinedLoader, "_create_eager_join"),
|
251
|
+
(JoinedLoader, "_generate_cache_key"),
|
252
|
+
]
|
253
|
+
|
254
|
+
wrapper = create_loader_wrapper(JoinedLoader, "joined", True)
|
255
|
+
|
256
|
+
for loader_class, method_name in joined_methods:
|
257
|
+
if hasattr(loader_class, method_name):
|
258
|
+
original_method = getattr(loader_class, method_name)
|
259
|
+
key = f"{loader_class.__name__}.{method_name}"
|
260
|
+
|
261
|
+
_instrumentation_state["original_methods"][key] = original_method
|
262
|
+
setattr(loader_class, method_name, wrapper(original_method))
|
263
|
+
|
264
|
+
|
265
|
+
def _instrument_loading_functions() -> None:
|
266
|
+
"""Instrument SQLAlchemy loading functions."""
|
267
|
+
|
268
|
+
def create_loading_wrapper(func_name: str):
|
269
|
+
"""Create a wrapper for loading functions."""
|
270
|
+
|
271
|
+
def wrapper(original_func: Callable):
|
272
|
+
@wraps(original_func)
|
273
|
+
def instrumented_func(*args, **kwargs):
|
274
|
+
span = _create_sync_db_span(
|
275
|
+
operation_type="loading_function",
|
276
|
+
additional_attrs={
|
277
|
+
"sqlalchemy.loading.function": func_name,
|
278
|
+
},
|
279
|
+
)
|
280
|
+
|
281
|
+
try:
|
282
|
+
result = original_func(*args, **kwargs)
|
283
|
+
if span:
|
284
|
+
span.set_status(Status(StatusCode.OK))
|
285
|
+
return result
|
286
|
+
except Exception as e:
|
287
|
+
if span:
|
288
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
289
|
+
raise
|
290
|
+
finally:
|
291
|
+
if span:
|
292
|
+
span.end()
|
293
|
+
|
294
|
+
return instrumented_func
|
295
|
+
|
296
|
+
return wrapper
|
297
|
+
|
298
|
+
# Instrument loading functions
|
299
|
+
import sqlalchemy.orm.loading as loading_module
|
300
|
+
|
301
|
+
functions_to_instrument = [
|
302
|
+
(loading_module, "load_on_ident", load_on_ident),
|
303
|
+
(loading_module, "load_on_pk_identity", load_on_pk_identity),
|
304
|
+
]
|
305
|
+
|
306
|
+
for module, func_name, original_func in functions_to_instrument:
|
307
|
+
wrapper = create_loading_wrapper(func_name)
|
308
|
+
|
309
|
+
# Store original function for cleanup
|
310
|
+
_instrumentation_state["original_methods"][f"loading.{func_name}"] = original_func
|
311
|
+
|
312
|
+
# Apply wrapper
|
313
|
+
setattr(module, func_name, wrapper(original_func))
|
314
|
+
|
315
|
+
|
316
|
+
def _instrument_session_operations() -> None:
|
317
|
+
"""Instrument SQLAlchemy session operations."""
|
318
|
+
|
319
|
+
def before_flush(session, flush_context, instances):
|
320
|
+
"""Track session flush operations."""
|
321
|
+
if not _config["enabled"]:
|
322
|
+
return
|
323
|
+
|
324
|
+
span = _create_sync_db_span(
|
325
|
+
operation_type="session_flush",
|
326
|
+
additional_attrs={
|
327
|
+
"sqlalchemy.session.new_count": len(session.new),
|
328
|
+
"sqlalchemy.session.dirty_count": len(session.dirty),
|
329
|
+
"sqlalchemy.session.deleted_count": len(session.deleted),
|
330
|
+
},
|
331
|
+
)
|
332
|
+
|
333
|
+
# Store span in session for cleanup
|
334
|
+
session._sync_instrumentation_flush_span = span
|
335
|
+
|
336
|
+
def after_flush(session, flush_context):
|
337
|
+
"""Track session flush completion."""
|
338
|
+
if not _config["enabled"]:
|
339
|
+
return
|
340
|
+
|
341
|
+
span = getattr(session, "_sync_instrumentation_flush_span", None)
|
342
|
+
if span:
|
343
|
+
span.set_status(Status(StatusCode.OK))
|
344
|
+
span.end()
|
345
|
+
session._sync_instrumentation_flush_span = None
|
346
|
+
|
347
|
+
def after_flush_postexec(session, flush_context):
|
348
|
+
"""Track session flush post-execution."""
|
349
|
+
if not _config["enabled"]:
|
350
|
+
return
|
351
|
+
|
352
|
+
span = getattr(session, "_sync_instrumentation_flush_span", None)
|
353
|
+
if span:
|
354
|
+
span.set_status(Status(StatusCode.OK))
|
355
|
+
span.end()
|
356
|
+
session._sync_instrumentation_flush_span = None
|
357
|
+
|
358
|
+
# Register session events
|
359
|
+
event.listen(Session, "before_flush", before_flush)
|
360
|
+
event.listen(Session, "after_flush", after_flush)
|
361
|
+
event.listen(Session, "after_flush_postexec", after_flush_postexec)
|
362
|
+
|
363
|
+
# Store listeners for cleanup
|
364
|
+
_instrumentation_state["session_listeners"].extend(
|
365
|
+
[
|
366
|
+
(Session, "before_flush", before_flush),
|
367
|
+
(Session, "after_flush", after_flush),
|
368
|
+
(Session, "after_flush_postexec", after_flush_postexec),
|
369
|
+
]
|
370
|
+
)
|
371
|
+
|
372
|
+
|
373
|
+
def setup_sqlalchemy_sync_instrumentation(
|
374
|
+
engines: Optional[List[Engine]] = None,
|
375
|
+
config_overrides: Optional[Dict[str, Any]] = None,
|
376
|
+
lazy_loading_only: bool = True,
|
377
|
+
) -> None:
|
378
|
+
"""
|
379
|
+
Set up SQLAlchemy synchronous operation instrumentation.
|
380
|
+
|
381
|
+
Args:
|
382
|
+
engines: List of SQLAlchemy engines to instrument. If None, will attempt
|
383
|
+
to discover engines automatically.
|
384
|
+
config_overrides: Dictionary of configuration overrides.
|
385
|
+
lazy_loading_only: If True, only instrument lazy loading operations.
|
386
|
+
"""
|
387
|
+
if _instrumentation_state["active"]:
|
388
|
+
return # Already active
|
389
|
+
|
390
|
+
try:
|
391
|
+
# Apply configuration overrides
|
392
|
+
if config_overrides:
|
393
|
+
_config.update(config_overrides)
|
394
|
+
|
395
|
+
# If lazy_loading_only is True, update config to focus on lazy loading
|
396
|
+
if lazy_loading_only:
|
397
|
+
_config.update(
|
398
|
+
{
|
399
|
+
"monitor_joined_loading": False, # Don't monitor joined loading
|
400
|
+
}
|
401
|
+
)
|
402
|
+
|
403
|
+
# Discover engines if not provided
|
404
|
+
if engines is None:
|
405
|
+
engines = []
|
406
|
+
# Try to find engines from the database registry
|
407
|
+
try:
|
408
|
+
from letta.server.db import db_registry
|
409
|
+
|
410
|
+
if hasattr(db_registry, "_async_engines"):
|
411
|
+
engines.extend(db_registry._async_engines.values())
|
412
|
+
if hasattr(db_registry, "_sync_engines"):
|
413
|
+
engines.extend(db_registry._sync_engines.values())
|
414
|
+
except ImportError:
|
415
|
+
pass
|
416
|
+
|
417
|
+
# Instrument loader strategies (focus on lazy loading if specified)
|
418
|
+
_instrument_loader_strategies()
|
419
|
+
|
420
|
+
# Instrument loading functions
|
421
|
+
_instrument_loading_functions()
|
422
|
+
|
423
|
+
# Instrument session operations
|
424
|
+
_instrument_session_operations()
|
425
|
+
|
426
|
+
# Instrument engines last to avoid potential errors with async engines
|
427
|
+
for engine in engines:
|
428
|
+
try:
|
429
|
+
_instrument_engine_events(engine)
|
430
|
+
except Exception as e:
|
431
|
+
if _config["log_instrumentation_errors"]:
|
432
|
+
print(f"Error instrumenting engine {engine}: {e}")
|
433
|
+
# Continue with other engines
|
434
|
+
|
435
|
+
_instrumentation_state["active"] = True
|
436
|
+
|
437
|
+
except Exception as e:
|
438
|
+
if _config["log_instrumentation_errors"]:
|
439
|
+
print(f"Error setting up SQLAlchemy instrumentation: {e}")
|
440
|
+
import traceback
|
441
|
+
|
442
|
+
traceback.print_exc()
|
443
|
+
raise
|
444
|
+
|
445
|
+
|
446
|
+
def teardown_sqlalchemy_sync_instrumentation() -> None:
|
447
|
+
"""Tear down SQLAlchemy synchronous operation instrumentation."""
|
448
|
+
if not _instrumentation_state["active"]:
|
449
|
+
return # Not active
|
450
|
+
|
451
|
+
try:
|
452
|
+
# Remove engine listeners
|
453
|
+
for engine, event_name, listener in _instrumentation_state["engine_listeners"]:
|
454
|
+
event.remove(engine, event_name, listener)
|
455
|
+
|
456
|
+
# Remove session listeners
|
457
|
+
for target, event_name, listener in _instrumentation_state["session_listeners"]:
|
458
|
+
event.remove(target, event_name, listener)
|
459
|
+
|
460
|
+
# Restore original methods
|
461
|
+
for key, original_method in _instrumentation_state["original_methods"].items():
|
462
|
+
if "." in key:
|
463
|
+
module_or_class_name, method_name = key.rsplit(".", 1)
|
464
|
+
|
465
|
+
if key.startswith("loading."):
|
466
|
+
# Restore loading function
|
467
|
+
import sqlalchemy.orm.loading as loading_module
|
468
|
+
|
469
|
+
setattr(loading_module, method_name, original_method)
|
470
|
+
else:
|
471
|
+
# Restore class method
|
472
|
+
class_name = module_or_class_name
|
473
|
+
# Find the class
|
474
|
+
for cls in [SelectInLoader, JoinedLoader, LazyLoader, SubqueryLoader, ImmediateLoader]:
|
475
|
+
if cls.__name__ == class_name:
|
476
|
+
setattr(cls, method_name, original_method)
|
477
|
+
break
|
478
|
+
|
479
|
+
# Clear state
|
480
|
+
_instrumentation_state["engine_listeners"].clear()
|
481
|
+
_instrumentation_state["session_listeners"].clear()
|
482
|
+
_instrumentation_state["original_methods"].clear()
|
483
|
+
_instrumentation_state["active"] = False
|
484
|
+
|
485
|
+
except Exception as e:
|
486
|
+
if _config["log_instrumentation_errors"]:
|
487
|
+
print(f"Error tearing down SQLAlchemy instrumentation: {e}")
|
488
|
+
traceback.print_exc()
|
489
|
+
raise
|
490
|
+
|
491
|
+
|
492
|
+
def configure_instrumentation(**kwargs) -> None:
|
493
|
+
"""
|
494
|
+
Configure SQLAlchemy synchronous operation instrumentation.
|
495
|
+
|
496
|
+
Args:
|
497
|
+
**kwargs: Configuration options to update.
|
498
|
+
"""
|
499
|
+
_config.update(kwargs)
|
500
|
+
|
501
|
+
|
502
|
+
def get_instrumentation_config() -> Dict[str, Any]:
|
503
|
+
"""Get current instrumentation configuration."""
|
504
|
+
return _config.copy()
|
505
|
+
|
506
|
+
|
507
|
+
def is_instrumentation_active() -> bool:
|
508
|
+
"""Check if instrumentation is currently active."""
|
509
|
+
return _instrumentation_state["active"]
|
510
|
+
|
511
|
+
|
512
|
+
# Context manager for temporary instrumentation
|
513
|
+
@contextmanager
|
514
|
+
def temporary_instrumentation(**config_overrides):
|
515
|
+
"""
|
516
|
+
Context manager for temporary SQLAlchemy instrumentation.
|
517
|
+
|
518
|
+
Args:
|
519
|
+
**config_overrides: Configuration overrides for the instrumentation.
|
520
|
+
"""
|
521
|
+
was_active = _instrumentation_state["active"]
|
522
|
+
|
523
|
+
if not was_active:
|
524
|
+
setup_sqlalchemy_sync_instrumentation(config_overrides=config_overrides)
|
525
|
+
|
526
|
+
try:
|
527
|
+
yield
|
528
|
+
finally:
|
529
|
+
if not was_active:
|
530
|
+
teardown_sqlalchemy_sync_instrumentation()
|
531
|
+
|
532
|
+
|
533
|
+
# FastAPI integration helper
|
534
|
+
def setup_fastapi_instrumentation(app):
|
535
|
+
"""
|
536
|
+
Set up SQLAlchemy instrumentation for FastAPI application.
|
537
|
+
|
538
|
+
Args:
|
539
|
+
app: FastAPI application instance
|
540
|
+
"""
|
541
|
+
|
542
|
+
@app.on_event("startup")
|
543
|
+
async def startup_instrumentation():
|
544
|
+
setup_sqlalchemy_sync_instrumentation()
|
545
|
+
|
546
|
+
@app.on_event("shutdown")
|
547
|
+
async def shutdown_instrumentation():
|
548
|
+
teardown_sqlalchemy_sync_instrumentation()
|
@@ -0,0 +1,124 @@
|
|
1
|
+
"""
|
2
|
+
Integration module for SQLAlchemy synchronous operation instrumentation.
|
3
|
+
|
4
|
+
This module provides easy integration with the existing Letta application,
|
5
|
+
including automatic discovery of database engines and integration with
|
6
|
+
the existing OpenTelemetry setup.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import logging
|
10
|
+
from typing import Any, Dict, Optional
|
11
|
+
|
12
|
+
from letta.otel.sqlalchemy_instrumentation import (
|
13
|
+
configure_instrumentation,
|
14
|
+
get_instrumentation_config,
|
15
|
+
is_instrumentation_active,
|
16
|
+
setup_sqlalchemy_sync_instrumentation,
|
17
|
+
teardown_sqlalchemy_sync_instrumentation,
|
18
|
+
)
|
19
|
+
from letta.server.db import db_registry
|
20
|
+
|
21
|
+
logger = logging.getLogger(__name__)
|
22
|
+
|
23
|
+
|
24
|
+
def setup_letta_db_instrumentation(
|
25
|
+
enable_joined_monitoring: bool = True,
|
26
|
+
sql_truncate_length: int = 1000,
|
27
|
+
additional_config: Optional[Dict[str, Any]] = None,
|
28
|
+
) -> None:
|
29
|
+
"""
|
30
|
+
Set up SQLAlchemy instrumentation for Letta application.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
enable_joined_monitoring: Whether to monitor joined loading operations
|
34
|
+
sql_truncate_length: Maximum length of SQL statements in traces
|
35
|
+
additional_config: Additional configuration options
|
36
|
+
"""
|
37
|
+
if is_instrumentation_active():
|
38
|
+
logger.info("SQLAlchemy instrumentation already active")
|
39
|
+
return
|
40
|
+
|
41
|
+
# Build configuration
|
42
|
+
config = {
|
43
|
+
"enabled": True,
|
44
|
+
"monitor_joined_loading": enable_joined_monitoring,
|
45
|
+
"sql_truncate_length": sql_truncate_length,
|
46
|
+
"log_instrumentation_errors": True,
|
47
|
+
}
|
48
|
+
|
49
|
+
if additional_config:
|
50
|
+
config.update(additional_config)
|
51
|
+
|
52
|
+
# Get engines from db_registry
|
53
|
+
engines = []
|
54
|
+
try:
|
55
|
+
if hasattr(db_registry, "_async_engines"):
|
56
|
+
engines.extend(db_registry._async_engines.values())
|
57
|
+
if hasattr(db_registry, "_sync_engines"):
|
58
|
+
engines.extend(db_registry._sync_engines.values())
|
59
|
+
except Exception as e:
|
60
|
+
logger.warning(f"Could not discover engines from db_registry: {e}")
|
61
|
+
|
62
|
+
if not engines:
|
63
|
+
logger.warning("No SQLAlchemy engines found for instrumentation")
|
64
|
+
return
|
65
|
+
|
66
|
+
try:
|
67
|
+
setup_sqlalchemy_sync_instrumentation(
|
68
|
+
engines=engines,
|
69
|
+
config_overrides=config,
|
70
|
+
)
|
71
|
+
logger.info(f"SQLAlchemy instrumentation setup complete for {len(engines)} engines")
|
72
|
+
|
73
|
+
# Log configuration
|
74
|
+
logger.info("Instrumentation configuration:")
|
75
|
+
for key, value in get_instrumentation_config().items():
|
76
|
+
logger.info(f" {key}: {value}")
|
77
|
+
|
78
|
+
except Exception as e:
|
79
|
+
logger.error(f"Failed to setup SQLAlchemy instrumentation: {e}")
|
80
|
+
raise
|
81
|
+
|
82
|
+
|
83
|
+
def teardown_letta_db_instrumentation() -> None:
|
84
|
+
"""Tear down SQLAlchemy instrumentation for Letta application."""
|
85
|
+
if not is_instrumentation_active():
|
86
|
+
logger.info("SQLAlchemy instrumentation not active")
|
87
|
+
return
|
88
|
+
|
89
|
+
try:
|
90
|
+
teardown_sqlalchemy_sync_instrumentation()
|
91
|
+
logger.info("SQLAlchemy instrumentation teardown complete")
|
92
|
+
except Exception as e:
|
93
|
+
logger.error(f"Failed to teardown SQLAlchemy instrumentation: {e}")
|
94
|
+
raise
|
95
|
+
|
96
|
+
|
97
|
+
def configure_letta_db_instrumentation(**kwargs) -> None:
|
98
|
+
"""
|
99
|
+
Configure SQLAlchemy instrumentation for Letta application.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
**kwargs: Configuration options to update
|
103
|
+
"""
|
104
|
+
configure_instrumentation(**kwargs)
|
105
|
+
logger.info(f"SQLAlchemy instrumentation configuration updated: {kwargs}")
|
106
|
+
|
107
|
+
|
108
|
+
# FastAPI integration
|
109
|
+
def setup_fastapi_db_instrumentation(app, **config_kwargs):
|
110
|
+
"""
|
111
|
+
Set up SQLAlchemy instrumentation for FastAPI application.
|
112
|
+
|
113
|
+
Args:
|
114
|
+
app: FastAPI application instance
|
115
|
+
**config_kwargs: Configuration options for instrumentation
|
116
|
+
"""
|
117
|
+
|
118
|
+
@app.on_event("startup")
|
119
|
+
async def startup_db_instrumentation():
|
120
|
+
setup_letta_db_instrumentation(**config_kwargs)
|
121
|
+
|
122
|
+
@app.on_event("shutdown")
|
123
|
+
async def shutdown_db_instrumentation():
|
124
|
+
teardown_letta_db_instrumentation()
|