langgraph-api 0.4.1__py3-none-any.whl → 0.7.3__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 (135) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +111 -51
  3. langgraph_api/api/a2a.py +1610 -0
  4. langgraph_api/api/assistants.py +212 -89
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +52 -28
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +342 -195
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +209 -27
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/asyncio.py +14 -4
  14. langgraph_api/auth/custom.py +52 -37
  15. langgraph_api/auth/langsmith/backend.py +4 -3
  16. langgraph_api/auth/langsmith/client.py +13 -8
  17. langgraph_api/cli.py +230 -133
  18. langgraph_api/command.py +5 -3
  19. langgraph_api/config/__init__.py +532 -0
  20. langgraph_api/config/_parse.py +58 -0
  21. langgraph_api/config/schemas.py +431 -0
  22. langgraph_api/cron_scheduler.py +17 -1
  23. langgraph_api/encryption/__init__.py +15 -0
  24. langgraph_api/encryption/aes_json.py +158 -0
  25. langgraph_api/encryption/context.py +35 -0
  26. langgraph_api/encryption/custom.py +280 -0
  27. langgraph_api/encryption/middleware.py +632 -0
  28. langgraph_api/encryption/shared.py +63 -0
  29. langgraph_api/errors.py +12 -1
  30. langgraph_api/executor_entrypoint.py +11 -6
  31. langgraph_api/feature_flags.py +29 -0
  32. langgraph_api/graph.py +176 -76
  33. langgraph_api/grpc/client.py +313 -0
  34. langgraph_api/grpc/config_conversion.py +231 -0
  35. langgraph_api/grpc/generated/__init__.py +29 -0
  36. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  37. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  38. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  39. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  40. langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
  41. langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
  42. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  43. langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
  44. langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
  45. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  46. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  47. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  48. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  49. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  50. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  51. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  52. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  53. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  54. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  55. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  56. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  57. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  58. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  59. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  60. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  61. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  62. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  63. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  64. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  65. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  66. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  67. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  68. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  69. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  70. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  71. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  72. langgraph_api/grpc/ops/__init__.py +370 -0
  73. langgraph_api/grpc/ops/assistants.py +424 -0
  74. langgraph_api/grpc/ops/runs.py +792 -0
  75. langgraph_api/grpc/ops/threads.py +1013 -0
  76. langgraph_api/http.py +16 -5
  77. langgraph_api/http_metrics.py +15 -35
  78. langgraph_api/http_metrics_utils.py +38 -0
  79. langgraph_api/js/build.mts +1 -1
  80. langgraph_api/js/client.http.mts +13 -7
  81. langgraph_api/js/client.mts +2 -5
  82. langgraph_api/js/package.json +29 -28
  83. langgraph_api/js/remote.py +56 -30
  84. langgraph_api/js/src/graph.mts +20 -0
  85. langgraph_api/js/sse.py +2 -2
  86. langgraph_api/js/ui.py +1 -1
  87. langgraph_api/js/yarn.lock +1204 -1006
  88. langgraph_api/logging.py +29 -2
  89. langgraph_api/metadata.py +99 -28
  90. langgraph_api/middleware/http_logger.py +7 -2
  91. langgraph_api/middleware/private_network.py +7 -7
  92. langgraph_api/models/run.py +54 -93
  93. langgraph_api/otel_context.py +205 -0
  94. langgraph_api/patch.py +5 -3
  95. langgraph_api/queue_entrypoint.py +154 -65
  96. langgraph_api/route.py +47 -5
  97. langgraph_api/schema.py +88 -10
  98. langgraph_api/self_hosted_logs.py +124 -0
  99. langgraph_api/self_hosted_metrics.py +450 -0
  100. langgraph_api/serde.py +79 -37
  101. langgraph_api/server.py +138 -60
  102. langgraph_api/state.py +4 -3
  103. langgraph_api/store.py +25 -16
  104. langgraph_api/stream.py +80 -29
  105. langgraph_api/thread_ttl.py +31 -13
  106. langgraph_api/timing/__init__.py +25 -0
  107. langgraph_api/timing/profiler.py +200 -0
  108. langgraph_api/timing/timer.py +318 -0
  109. langgraph_api/utils/__init__.py +53 -8
  110. langgraph_api/utils/cache.py +47 -10
  111. langgraph_api/utils/config.py +2 -1
  112. langgraph_api/utils/errors.py +77 -0
  113. langgraph_api/utils/future.py +10 -6
  114. langgraph_api/utils/headers.py +76 -2
  115. langgraph_api/utils/retriable_client.py +74 -0
  116. langgraph_api/utils/stream_codec.py +315 -0
  117. langgraph_api/utils/uuids.py +29 -62
  118. langgraph_api/validation.py +9 -0
  119. langgraph_api/webhook.py +120 -6
  120. langgraph_api/worker.py +55 -24
  121. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
  122. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  123. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  124. langgraph_runtime/__init__.py +1 -0
  125. langgraph_runtime/routes.py +11 -0
  126. logging.json +1 -3
  127. openapi.json +839 -478
  128. langgraph_api/config.py +0 -387
  129. langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
  130. langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
  131. langgraph_api/js/package-lock.json +0 -3308
  132. langgraph_api-0.4.1.dist-info/RECORD +0 -107
  133. /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
  134. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  135. {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,34 +1,52 @@
1
1
  """Sweeping logic for cleaning up expired threads and checkpoints."""
2
2
 
3
3
  import asyncio
4
+ from typing import cast
4
5
 
5
6
  import structlog
6
7
 
7
8
  from langgraph_api.config import THREAD_TTL
9
+ from langgraph_api.feature_flags import FF_USE_CORE_API
8
10
  from langgraph_runtime.database import connect
9
11
 
10
12
  logger = structlog.stdlib.get_logger(__name__)
11
13
 
14
+ # Supported TTL strategies
15
+ SUPPORTED_STRATEGIES = {"delete", "keep_latest"}
16
+
12
17
 
13
18
  async def thread_ttl_sweep_loop():
14
- """Periodically delete threads based on TTL configuration.
19
+ """Periodically sweep threads based on TTL configuration.
20
+
21
+ Supported strategies:
22
+ - 'delete': Remove the thread and all its data entirely
23
+ - 'keep_latest': Prune old checkpoints but keep thread and latest state
24
+ (requires FF_USE_CORE_API=true)
15
25
 
16
- Currently implements the 'delete' strategy, which deletes entire threads
17
- that have been inactive for longer than their configured TTL period.
26
+ Per-thread TTL strategies are stored in the thread_ttl table and can vary
27
+ by thread. This loop processes all expired threads regardless of strategy.
18
28
  """
19
- # Use the same interval as store TTL sweep
20
29
  thread_ttl_config = THREAD_TTL or {}
21
- strategy = thread_ttl_config.get("strategy", "delete")
22
- if strategy != "delete":
23
- raise NotImplementedError(
24
- f"Unrecognized thread deletion strategy: {strategy}. Expected 'delete'."
25
- )
26
- sweep_interval_minutes = thread_ttl_config.get("sweep_interval_minutes", 5)
30
+ default_strategy = thread_ttl_config.get("strategy", "delete")
31
+ sweep_interval_minutes = cast(
32
+ "int", thread_ttl_config.get("sweep_interval_minutes", 5)
33
+ )
34
+ sweep_limit = thread_ttl_config.get("sweep_limit", 1000)
35
+
27
36
  await logger.ainfo(
28
- f"Starting thread TTL sweeper with interval {sweep_interval_minutes} minutes",
29
- strategy=strategy,
37
+ "Starting thread TTL sweeper",
38
+ default_strategy=default_strategy,
30
39
  interval_minutes=sweep_interval_minutes,
40
+ sweep_limit=sweep_limit,
41
+ core_api_enabled=FF_USE_CORE_API,
31
42
  )
43
+
44
+ if default_strategy == "keep_latest" and not FF_USE_CORE_API:
45
+ await logger.awarning(
46
+ "keep_latest strategy configured but FF_USE_CORE_API is not enabled. "
47
+ "Threads with keep_latest strategy will be skipped during sweep."
48
+ )
49
+
32
50
  loop = asyncio.get_running_loop()
33
51
 
34
52
  from langgraph_runtime.ops import Threads
@@ -41,7 +59,7 @@ async def thread_ttl_sweep_loop():
41
59
  threads_processed, threads_deleted = await Threads.sweep_ttl(conn)
42
60
  if threads_processed > 0:
43
61
  await logger.ainfo(
44
- f"Thread TTL sweep completed. Processed {threads_processed}",
62
+ "Thread TTL sweep completed",
45
63
  threads_processed=threads_processed,
46
64
  threads_deleted=threads_deleted,
47
65
  duration=loop.time() - sweep_start,
@@ -0,0 +1,25 @@
1
+ """Timing utilities for startup profiling and performance monitoring."""
2
+
3
+ from langgraph_api.timing.profiler import (
4
+ profiled_import,
5
+ )
6
+ from langgraph_api.timing.timer import (
7
+ TimerConfig,
8
+ aenter_timed,
9
+ combine_lifespans,
10
+ get_startup_elapsed,
11
+ time_aenter,
12
+ timer,
13
+ wrap_lifespan_context_aenter,
14
+ )
15
+
16
+ __all__ = [
17
+ "TimerConfig",
18
+ "aenter_timed",
19
+ "combine_lifespans",
20
+ "get_startup_elapsed",
21
+ "profiled_import",
22
+ "time_aenter",
23
+ "timer",
24
+ "wrap_lifespan_context_aenter",
25
+ ]
@@ -0,0 +1,200 @@
1
+ """Import profiling utilities for diagnosing slow module loads.
2
+
3
+ When FF_PROFILE_IMPORTS is true, this module provides detailed
4
+ profiling of what's slow during module imports - including nested imports
5
+ and module-level code execution (network calls, file I/O, etc.).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import cProfile
11
+ import pstats
12
+ import time
13
+ from contextlib import contextmanager
14
+ from typing import TypedDict, cast
15
+
16
+ import structlog
17
+
18
+ logger = structlog.stdlib.get_logger(__name__)
19
+
20
+ # Minimum time (in seconds) for an operation to be reported
21
+ MIN_REPORT_THRESHOLD_SECS = 1.0
22
+
23
+
24
+ @contextmanager
25
+ def profiled_import(path: str, top_n: int = 10):
26
+ """Context manager for profiling an import with automatic reporting.
27
+
28
+ Usage:
29
+ with profiled_import("./my_module.py:obj") as profiler:
30
+ module = exec_module(...)
31
+ # Automatically logs slow calls if any
32
+
33
+ Args:
34
+ path: The module path (for logging)
35
+ top_n: Maximum number of slow calls to report
36
+ """
37
+ from langgraph_api import config
38
+
39
+ if not config.FF_PROFILE_IMPORTS:
40
+ yield None
41
+ return
42
+
43
+ start = time.perf_counter()
44
+ pr = cProfile.Profile()
45
+ pr.enable()
46
+
47
+ class ProfilerResult:
48
+ def __init__(self) -> None:
49
+ self.slow_calls: list[_SlowCall] = []
50
+ self.total_secs: float = 0.0
51
+
52
+ result = ProfilerResult()
53
+
54
+ try:
55
+ yield result
56
+ finally:
57
+ pr.disable()
58
+ result.total_secs = time.perf_counter() - start
59
+
60
+ # Extract the module filename from path (e.g., "./foo/bar.py:obj" -> "bar.py")
61
+ module_file = path.split(":")[0].rsplit("/", 1)[-1]
62
+
63
+ stats_obj = pstats.Stats(pr)
64
+ stats = cast("dict", stats_obj.stats) # type: ignore[attr-defined]
65
+ slow_calls: list[_SlowCall] = []
66
+
67
+ for (filename, lineno, funcname), (
68
+ _cc,
69
+ nc,
70
+ _tt,
71
+ ct,
72
+ callers,
73
+ ) in stats.items():
74
+ cumtime_secs = ct
75
+ if cumtime_secs >= MIN_REPORT_THRESHOLD_SECS:
76
+ # Skip non-actionable entries
77
+ if "cProfile" in filename or "<frozen" in filename:
78
+ continue
79
+ # Skip built-in exec (just wrapper around module execution)
80
+ if filename == "~" and "builtins.exec" in funcname:
81
+ continue
82
+ # Skip the top-level <module> entry (not actionable)
83
+ if funcname == "<module>":
84
+ continue
85
+
86
+ # Find call site in user's module
87
+ call_site = _find_user_call_site(callers, module_file, stats)
88
+
89
+ slow_calls.append(
90
+ _SlowCall(
91
+ function=funcname,
92
+ file=f"{filename}:{lineno}",
93
+ cumulative_secs=round(cumtime_secs, 2),
94
+ calls=nc,
95
+ call_site=call_site,
96
+ )
97
+ )
98
+
99
+ slow_calls.sort(key=lambda x: x["cumulative_secs"], reverse=True)
100
+ result.slow_calls = slow_calls[:top_n]
101
+
102
+ # Only log if we have slow calls worth reporting
103
+ if result.slow_calls:
104
+ report = _format_slow_calls_report(
105
+ path, result.total_secs, result.slow_calls
106
+ )
107
+ logger.warning(
108
+ f"slow_import_profile: {report}",
109
+ path=path,
110
+ total_secs=round(result.total_secs, 2),
111
+ slow_calls=result.slow_calls,
112
+ )
113
+
114
+
115
+ def _find_user_call_site(
116
+ callers: dict, module_file: str, all_stats: dict, max_depth: int = 20
117
+ ) -> str | None:
118
+ """Walk up the call chain to find where in the user's module this was called from."""
119
+ visited: set[tuple] = set()
120
+ to_check = list(callers.keys())
121
+
122
+ for _ in range(max_depth):
123
+ if not to_check:
124
+ break
125
+ caller_key = to_check.pop(0)
126
+ if caller_key in visited:
127
+ continue
128
+ visited.add(caller_key)
129
+
130
+ caller_file, caller_line, caller_func = caller_key
131
+ # Found a call from the user's module
132
+ if caller_file.endswith(module_file):
133
+ # cProfile attributes all module-level code to <module> at line 1,
134
+ # so we can't get the actual line number for top-level calls
135
+ if caller_func == "<module>":
136
+ return f"{module_file} (module-level)"
137
+ return f"{module_file}:{caller_line} in {caller_func}()"
138
+
139
+ # Keep walking up
140
+ if caller_key in all_stats:
141
+ parent_callers = all_stats[caller_key][4] # callers is index 4
142
+ to_check.extend(parent_callers.keys())
143
+
144
+ return None
145
+
146
+
147
+ class _SlowCall(TypedDict):
148
+ """A slow function call detected during import profiling."""
149
+
150
+ function: str
151
+ file: str
152
+ cumulative_secs: float
153
+ calls: int
154
+ call_site: str | None # Where in user's module this was called from
155
+
156
+
157
+ def _format_slow_calls_report(
158
+ path: str,
159
+ total_secs: float,
160
+ slow_calls: list[_SlowCall],
161
+ ) -> str:
162
+ """Format slow calls into a human-readable report."""
163
+ lines = [
164
+ "",
165
+ f"Slow startup for '{path}' ({total_secs:.1f}s)",
166
+ "",
167
+ " Slowest operations:",
168
+ ]
169
+
170
+ for call in slow_calls:
171
+ secs = call["cumulative_secs"]
172
+ func = call["function"]
173
+ # Show last 2 path components for context (e.g., "requests/sessions.py:500")
174
+ file_path = call["file"]
175
+ parts = file_path.rsplit("/", 2)
176
+ loc = "/".join(parts[-2:]) if len(parts) > 2 else file_path
177
+
178
+ call_site = call.get("call_site")
179
+ if call_site:
180
+ lines.append(f" {secs:>6.2f}s {func:<24} {loc}")
181
+ lines.append(f" ↳ from {call_site}")
182
+ else:
183
+ lines.append(f" {secs:>6.2f}s {func:<24} {loc}")
184
+
185
+ lines.extend(
186
+ [
187
+ "",
188
+ " Slow operations (network calls, file I/O, heavy computation) at",
189
+ " import time delay startup. Consider moving them inside functions",
190
+ " or using lazy initialization.",
191
+ "",
192
+ ]
193
+ )
194
+
195
+ return "\n".join(lines)
196
+
197
+
198
+ __all__ = [
199
+ "profiled_import",
200
+ ]
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: E402
4
+ import time
5
+
6
+ # Capture the time when this module is first imported (early in server startup)
7
+ _PROCESS_START_TIME = time.monotonic()
8
+
9
+ import contextlib
10
+ import functools
11
+ import inspect
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar, overload
15
+
16
+ import structlog
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+ from contextlib import AbstractAsyncContextManager
21
+
22
+ from starlette.applications import Starlette
23
+
24
+ logger = structlog.stdlib.get_logger(__name__)
25
+
26
+ P = ParamSpec("P")
27
+ R = TypeVar("R")
28
+ T = TypeVar("T", covariant=True)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TimerConfig(Generic[P]):
33
+ message: str = "Function timing"
34
+ metadata_fn: Callable[P, dict[str, Any]] | None = None
35
+ warn_threshold_secs: float | None = None
36
+ warn_message: str | None = None
37
+ error_threshold_secs: float | None = None
38
+ error_message: str | None = None
39
+
40
+
41
+ @overload
42
+ def timer(_func: Callable[P, R], /, **kwargs) -> Callable[P, R]: ...
43
+ @overload
44
+ def timer(
45
+ _func: None = None, /, **kwargs
46
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
47
+
48
+
49
+ def timer(
50
+ _func: Callable[P, R] | None = None,
51
+ /,
52
+ message: str = "Function timing",
53
+ metadata_fn: Callable[P, dict[str, Any]] | None = None,
54
+ warn_threshold_secs: float | None = None,
55
+ warn_message: str | None = None,
56
+ error_threshold_secs: float | None = None,
57
+ error_message: str | None = None,
58
+ ):
59
+ """
60
+ Decorator for sync *and* async callables.
61
+ """
62
+ cfg = TimerConfig[P](
63
+ message=message,
64
+ metadata_fn=metadata_fn,
65
+ warn_threshold_secs=warn_threshold_secs,
66
+ warn_message=warn_message,
67
+ error_threshold_secs=error_threshold_secs,
68
+ error_message=error_message,
69
+ )
70
+
71
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
72
+ if inspect.iscoroutinefunction(func):
73
+
74
+ @functools.wraps(func)
75
+ async def awrapper(*args: P.args, **kwargs: P.kwargs) -> R:
76
+ start = time.perf_counter()
77
+ exc: BaseException | None = None
78
+ try:
79
+ return await func(*args, **kwargs) # type: ignore[misc]
80
+ except BaseException as e:
81
+ exc = e
82
+ raise
83
+ finally:
84
+ elapsed = time.perf_counter() - start
85
+ _log_timing(
86
+ name=func.__qualname__,
87
+ elapsed=elapsed,
88
+ cfg=cfg, # type: ignore[arg-type]
89
+ args=args,
90
+ kwargs=kwargs,
91
+ exc=exc,
92
+ )
93
+
94
+ return awrapper # type: ignore[return-value]
95
+
96
+ @functools.wraps(func)
97
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
98
+ start = time.perf_counter()
99
+ exc: BaseException | None = None
100
+ try:
101
+ return func(*args, **kwargs)
102
+ except BaseException as e:
103
+ exc = e
104
+ raise
105
+ finally:
106
+ elapsed = time.perf_counter() - start
107
+ _log_timing(
108
+ name=func.__qualname__,
109
+ elapsed=elapsed,
110
+ cfg=cfg, # type: ignore[arg-type]
111
+ args=args,
112
+ kwargs=kwargs,
113
+ exc=exc,
114
+ )
115
+
116
+ return wrapper
117
+
118
+ return decorator(_func) if _func is not None else decorator
119
+
120
+
121
+ class aenter_timed(Generic[T]):
122
+ """
123
+ Wraps an async context manager and logs the time spent in *its __aenter__*.
124
+ __aexit__ is delegated without additional timing.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ inner: AbstractAsyncContextManager[T],
130
+ *,
131
+ name: str,
132
+ cfg: TimerConfig[Any],
133
+ extra: dict[str, Any] | None = None,
134
+ ) -> None:
135
+ self._inner = inner
136
+ self._name = name
137
+ self._cfg = cfg
138
+ self._extra = extra or {}
139
+
140
+ async def __aenter__(self) -> T:
141
+ start = time.perf_counter()
142
+ exc: BaseException | None = None
143
+ try:
144
+ return await self._inner.__aenter__()
145
+ except BaseException as e:
146
+ exc = e
147
+ raise
148
+ finally:
149
+ elapsed = time.perf_counter() - start
150
+ _log_timing(
151
+ name=self._name,
152
+ elapsed=elapsed,
153
+ cfg=self._cfg,
154
+ exc=exc,
155
+ extra=self._extra,
156
+ )
157
+
158
+ async def __aexit__(self, exc_type, exc, tb) -> bool | None:
159
+ return await self._inner.__aexit__(exc_type, exc, tb)
160
+
161
+
162
+ def time_aenter(
163
+ cm: AbstractAsyncContextManager[T],
164
+ *,
165
+ name: str,
166
+ message: str,
167
+ warn_threshold_secs: float | None = None,
168
+ warn_message: str | None = None,
169
+ error_threshold_secs: float | None = None,
170
+ error_message: str | None = None,
171
+ extra: dict[str, Any] | None = None,
172
+ ) -> aenter_timed[T]:
173
+ """
174
+ Convenience helper to wrap any async CM and time only its __aenter__.
175
+ """
176
+ cfg = TimerConfig[Any](
177
+ message=message,
178
+ warn_threshold_secs=warn_threshold_secs,
179
+ warn_message=warn_message,
180
+ error_threshold_secs=error_threshold_secs,
181
+ error_message=error_message,
182
+ metadata_fn=None,
183
+ )
184
+ return aenter_timed(cm, name=name, cfg=cfg, extra=extra)
185
+
186
+
187
+ def wrap_lifespan_context_aenter(
188
+ lifespan_ctx: Callable[[Any], AbstractAsyncContextManager[Any]],
189
+ *,
190
+ name: str = "user_router.lifespan",
191
+ message: str = "Entered lifespan context",
192
+ warn_threshold_secs: float | None = 10,
193
+ warn_message: str | None = (
194
+ "User lifespan startup exceeded expected time. "
195
+ "Slow work done at entry time within lifespan context can delay readiness, "
196
+ "reduce scale-out capacity, and may cause deployments to be marked unhealthy."
197
+ ),
198
+ error_threshold_secs: float | None = 30,
199
+ error_message: str | None = None,
200
+ ) -> Callable[[Any], AbstractAsyncContextManager[Any]]:
201
+ @functools.wraps(lifespan_ctx)
202
+ def wrapped(app: Any) -> AbstractAsyncContextManager[Any]:
203
+ return time_aenter(
204
+ lifespan_ctx(app),
205
+ name=name,
206
+ message=message,
207
+ warn_threshold_secs=warn_threshold_secs,
208
+ warn_message=warn_message,
209
+ error_threshold_secs=error_threshold_secs,
210
+ error_message=error_message,
211
+ )
212
+
213
+ return wrapped
214
+
215
+
216
+ LP = ParamSpec("LP")
217
+
218
+
219
+ def combine_lifespans(
220
+ *lifespans: Callable[[Starlette], AbstractAsyncContextManager] | None,
221
+ ) -> Callable[[Starlette], AbstractAsyncContextManager]:
222
+ @contextlib.asynccontextmanager
223
+ async def combined_lifespan(app):
224
+ async with contextlib.AsyncExitStack() as stack:
225
+ for ls in lifespans:
226
+ if ls is not None:
227
+ await stack.enter_async_context(ls(app))
228
+ elapsed = get_startup_elapsed()
229
+ logger.info(f"Application started up in {elapsed:2.3f}s", elapsed=elapsed)
230
+ yield
231
+
232
+ return combined_lifespan
233
+
234
+
235
+ def get_startup_elapsed() -> float:
236
+ """Return elapsed seconds since the process started (module import time)."""
237
+ return time.monotonic() - _PROCESS_START_TIME
238
+
239
+
240
+ def _log_timing(
241
+ *,
242
+ name: str,
243
+ elapsed: float,
244
+ cfg: TimerConfig[Any],
245
+ args: tuple[Any, ...] = (),
246
+ kwargs: dict[str, Any] | None = None,
247
+ exc: BaseException | None = None,
248
+ extra: dict[str, Any] | None = None,
249
+ ) -> None:
250
+ from langgraph_api import config
251
+
252
+ kwargs = kwargs or {}
253
+
254
+ log_data: dict[str, Any] = {
255
+ "name": name,
256
+ "elapsed_seconds": elapsed,
257
+ }
258
+
259
+ if extra:
260
+ log_data.update(extra)
261
+
262
+ if cfg.metadata_fn is not None:
263
+ try:
264
+ md = cfg.metadata_fn(*args, **kwargs) # type: ignore[misc]
265
+ if not isinstance(md, dict):
266
+ raise TypeError("metadata_fn must return a dict")
267
+ log_data.update(md)
268
+ except Exception as meta_exc:
269
+ log_data["metadata_error"] = repr(meta_exc)
270
+
271
+ if exc is not None:
272
+ log_data["exception"] = repr(exc)
273
+
274
+ level, msg = _pick_level_and_message(
275
+ elapsed=elapsed,
276
+ message=cfg.message,
277
+ warn_threshold_secs=cfg.warn_threshold_secs,
278
+ warn_message=cfg.warn_message,
279
+ error_threshold_secs=cfg.error_threshold_secs,
280
+ error_message=cfg.error_message,
281
+ )
282
+
283
+ # Allow {graph_id} etc.
284
+ msg = msg.format(**log_data)
285
+
286
+ # Add profiler hint if we hit warn/error threshold and profiling isn't enabled
287
+ if level >= logging.WARNING and not config.FF_PROFILE_IMPORTS:
288
+ msg = (
289
+ f"{msg}\n"
290
+ f" To get detailed profiling of slow operations, set FF_PROFILE_IMPORTS=true"
291
+ )
292
+
293
+ logger.log(level, msg, **log_data)
294
+
295
+
296
+ def _pick_level_and_message(
297
+ *,
298
+ elapsed: float,
299
+ message: str,
300
+ warn_threshold_secs: float | None,
301
+ warn_message: str | None,
302
+ error_threshold_secs: float | None,
303
+ error_message: str | None,
304
+ ) -> tuple[int, str]:
305
+ level = logging.INFO
306
+ msg = message
307
+
308
+ if warn_threshold_secs is not None and elapsed > warn_threshold_secs:
309
+ level = logging.WARNING
310
+ if warn_message is not None:
311
+ msg = warn_message
312
+
313
+ if error_threshold_secs is not None and elapsed > error_threshold_secs:
314
+ level = logging.ERROR
315
+ if error_message is not None:
316
+ msg = error_message
317
+
318
+ return level, msg