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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +111 -51
- langgraph_api/api/a2a.py +1610 -0
- langgraph_api/api/assistants.py +212 -89
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +52 -28
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +342 -195
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +209 -27
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/asyncio.py +14 -4
- langgraph_api/auth/custom.py +52 -37
- langgraph_api/auth/langsmith/backend.py +4 -3
- langgraph_api/auth/langsmith/client.py +13 -8
- langgraph_api/cli.py +230 -133
- langgraph_api/command.py +5 -3
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +29 -0
- langgraph_api/graph.py +176 -76
- langgraph_api/grpc/client.py +313 -0
- langgraph_api/grpc/config_conversion.py +231 -0
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/grpc/generated/core_api_pb2.pyi +905 -0
- langgraph_api/grpc/generated/core_api_pb2_grpc.py +1621 -0
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/grpc/generated/engine_common_pb2.pyi +722 -0
- langgraph_api/grpc/generated/engine_common_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/http_metrics.py +15 -35
- langgraph_api/http_metrics_utils.py +38 -0
- langgraph_api/js/build.mts +1 -1
- langgraph_api/js/client.http.mts +13 -7
- langgraph_api/js/client.mts +2 -5
- langgraph_api/js/package.json +29 -28
- langgraph_api/js/remote.py +56 -30
- langgraph_api/js/src/graph.mts +20 -0
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1204 -1006
- langgraph_api/logging.py +29 -2
- langgraph_api/metadata.py +99 -28
- langgraph_api/middleware/http_logger.py +7 -2
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +54 -93
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +5 -3
- langgraph_api/queue_entrypoint.py +154 -65
- langgraph_api/route.py +47 -5
- langgraph_api/schema.py +88 -10
- langgraph_api/self_hosted_logs.py +124 -0
- langgraph_api/self_hosted_metrics.py +450 -0
- langgraph_api/serde.py +79 -37
- langgraph_api/server.py +138 -60
- langgraph_api/state.py +4 -3
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +80 -29
- langgraph_api/thread_ttl.py +31 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/cache.py +47 -10
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/errors.py +77 -0
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/headers.py +76 -2
- langgraph_api/utils/retriable_client.py +74 -0
- langgraph_api/utils/stream_codec.py +315 -0
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +9 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +55 -24
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +16 -8
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +839 -478
- langgraph_api/config.py +0 -387
- langgraph_api/js/isolate-0x130008000-46649-46649-v8.log +0 -4430
- langgraph_api/js/isolate-0x138008000-44681-44681-v8.log +0 -4430
- langgraph_api/js/package-lock.json +0 -3308
- langgraph_api-0.4.1.dist-info/RECORD +0 -107
- /langgraph_api/{utils.py → grpc/__init__.py} +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.4.1.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/thread_ttl.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|