letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721070720__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.
Files changed (99) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +27 -11
  3. letta/agents/helpers.py +1 -1
  4. letta/agents/letta_agent.py +518 -322
  5. letta/agents/letta_agent_batch.py +1 -2
  6. letta/agents/voice_agent.py +15 -17
  7. letta/client/client.py +3 -3
  8. letta/constants.py +5 -0
  9. letta/embeddings.py +0 -2
  10. letta/errors.py +8 -0
  11. letta/functions/function_sets/base.py +3 -3
  12. letta/functions/helpers.py +2 -3
  13. letta/groups/sleeptime_multi_agent.py +0 -1
  14. letta/helpers/composio_helpers.py +2 -2
  15. letta/helpers/converters.py +1 -1
  16. letta/helpers/pinecone_utils.py +8 -0
  17. letta/helpers/tool_rule_solver.py +13 -18
  18. letta/llm_api/aws_bedrock.py +16 -2
  19. letta/llm_api/cohere.py +1 -1
  20. letta/llm_api/openai_client.py +1 -1
  21. letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
  22. letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
  23. letta/local_llm/utils.py +1 -2
  24. letta/orm/agent.py +3 -3
  25. letta/orm/block.py +4 -4
  26. letta/orm/files_agents.py +0 -1
  27. letta/orm/identity.py +2 -0
  28. letta/orm/mcp_server.py +0 -2
  29. letta/orm/message.py +140 -14
  30. letta/orm/organization.py +5 -5
  31. letta/orm/passage.py +4 -4
  32. letta/orm/source.py +1 -1
  33. letta/orm/sqlalchemy_base.py +61 -39
  34. letta/orm/step.py +2 -0
  35. letta/otel/db_pool_monitoring.py +308 -0
  36. letta/otel/metric_registry.py +94 -1
  37. letta/otel/sqlalchemy_instrumentation.py +548 -0
  38. letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
  39. letta/otel/tracing.py +37 -1
  40. letta/schemas/agent.py +0 -3
  41. letta/schemas/agent_file.py +283 -0
  42. letta/schemas/block.py +0 -3
  43. letta/schemas/file.py +28 -26
  44. letta/schemas/letta_message.py +15 -4
  45. letta/schemas/memory.py +1 -1
  46. letta/schemas/message.py +31 -26
  47. letta/schemas/openai/chat_completion_response.py +0 -1
  48. letta/schemas/providers.py +20 -0
  49. letta/schemas/source.py +11 -13
  50. letta/schemas/step.py +12 -0
  51. letta/schemas/tool.py +0 -4
  52. letta/serialize_schemas/marshmallow_agent.py +14 -1
  53. letta/serialize_schemas/marshmallow_block.py +23 -1
  54. letta/serialize_schemas/marshmallow_message.py +1 -3
  55. letta/serialize_schemas/marshmallow_tool.py +23 -1
  56. letta/server/db.py +110 -6
  57. letta/server/rest_api/app.py +85 -73
  58. letta/server/rest_api/routers/v1/agents.py +68 -53
  59. letta/server/rest_api/routers/v1/blocks.py +2 -2
  60. letta/server/rest_api/routers/v1/jobs.py +3 -0
  61. letta/server/rest_api/routers/v1/organizations.py +2 -2
  62. letta/server/rest_api/routers/v1/sources.py +18 -2
  63. letta/server/rest_api/routers/v1/tools.py +11 -12
  64. letta/server/rest_api/routers/v1/users.py +1 -1
  65. letta/server/rest_api/streaming_response.py +13 -5
  66. letta/server/rest_api/utils.py +8 -25
  67. letta/server/server.py +11 -4
  68. letta/server/ws_api/server.py +2 -2
  69. letta/services/agent_file_manager.py +616 -0
  70. letta/services/agent_manager.py +133 -46
  71. letta/services/block_manager.py +38 -17
  72. letta/services/file_manager.py +106 -21
  73. letta/services/file_processor/file_processor.py +93 -0
  74. letta/services/files_agents_manager.py +28 -0
  75. letta/services/group_manager.py +4 -5
  76. letta/services/helpers/agent_manager_helper.py +57 -9
  77. letta/services/identity_manager.py +22 -0
  78. letta/services/job_manager.py +210 -91
  79. letta/services/llm_batch_manager.py +9 -6
  80. letta/services/mcp/stdio_client.py +1 -2
  81. letta/services/mcp_manager.py +0 -1
  82. letta/services/message_manager.py +49 -26
  83. letta/services/passage_manager.py +0 -1
  84. letta/services/provider_manager.py +1 -1
  85. letta/services/source_manager.py +114 -5
  86. letta/services/step_manager.py +36 -4
  87. letta/services/telemetry_manager.py +9 -2
  88. letta/services/tool_executor/builtin_tool_executor.py +5 -1
  89. letta/services/tool_executor/core_tool_executor.py +3 -3
  90. letta/services/tool_manager.py +95 -20
  91. letta/services/user_manager.py +4 -12
  92. letta/settings.py +23 -6
  93. letta/system.py +1 -1
  94. letta/utils.py +26 -2
  95. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/METADATA +3 -2
  96. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/RECORD +99 -94
  97. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/LICENSE +0 -0
  98. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/WHEEL +0 -0
  99. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.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()