langgraph-api 0.5.4__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 +93 -27
- langgraph_api/api/a2a.py +36 -32
- langgraph_api/api/assistants.py +114 -26
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +15 -2
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +114 -57
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +133 -10
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/auth/custom.py +23 -13
- langgraph_api/cli.py +86 -41
- langgraph_api/command.py +2 -2
- 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 +19 -0
- langgraph_api/graph.py +163 -64
- langgraph_api/{grpc_ops → grpc}/client.py +142 -12
- langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
- 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_ops → grpc}/generated/core_api_pb2.pyi +292 -372
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
- 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/js/client.mts +1 -4
- langgraph_api/js/package.json +28 -27
- langgraph_api/js/remote.py +39 -17
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1139 -869
- langgraph_api/metadata.py +29 -3
- langgraph_api/middleware/http_logger.py +1 -1
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +44 -26
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +2 -2
- langgraph_api/queue_entrypoint.py +34 -35
- langgraph_api/route.py +33 -1
- langgraph_api/schema.py +84 -9
- langgraph_api/self_hosted_logs.py +2 -2
- langgraph_api/self_hosted_metrics.py +73 -3
- langgraph_api/serde.py +16 -4
- langgraph_api/server.py +33 -31
- langgraph_api/state.py +3 -2
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +20 -16
- langgraph_api/thread_ttl.py +28 -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/config.py +2 -1
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +6 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +54 -24
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.5.4.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 +635 -537
- langgraph_api/config.py +0 -523
- langgraph_api/grpc_ops/generated/__init__.py +0 -5
- langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
- langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
- langgraph_api/grpc_ops/ops.py +0 -1045
- langgraph_api-0.5.4.dist-info/RECORD +0 -121
- /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
- /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/metadata.py
CHANGED
|
@@ -14,6 +14,7 @@ from langgraph_api.config import (
|
|
|
14
14
|
LANGGRAPH_CLOUD_LICENSE_KEY,
|
|
15
15
|
LANGSMITH_AUTH_ENDPOINT,
|
|
16
16
|
LANGSMITH_CONTROL_PLANE_API_KEY,
|
|
17
|
+
LANGSMITH_LICENSE_REQUIRED_CLAIMS,
|
|
17
18
|
USES_CUSTOM_APP,
|
|
18
19
|
USES_CUSTOM_AUTH,
|
|
19
20
|
USES_INDEXING,
|
|
@@ -123,6 +124,9 @@ async def metadata_loop() -> None:
|
|
|
123
124
|
except ImportError:
|
|
124
125
|
__version__ = None
|
|
125
126
|
if not LANGGRAPH_CLOUD_LICENSE_KEY and not LANGSMITH_CONTROL_PLANE_API_KEY:
|
|
127
|
+
logger.info(
|
|
128
|
+
"No license key or control plane API key set, skipping metadata loop"
|
|
129
|
+
)
|
|
126
130
|
return
|
|
127
131
|
lg_version = langgraph.version.__version__
|
|
128
132
|
|
|
@@ -134,7 +138,21 @@ async def metadata_loop() -> None:
|
|
|
134
138
|
logger.info("Running in air-gapped mode, skipping metadata loop")
|
|
135
139
|
return
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
# TODO: This is a temporary "hack". A user could inadvertently include
|
|
142
|
+
# 'agent_builder_enabled' in LANGSMITH_LICENSE_REQUIRED_CLAIMS for a
|
|
143
|
+
# non-Agent Builder self-hosted deployment. If the 'agent_builder_enabled'
|
|
144
|
+
# entitlement is enabled, then this would bypass the metadata loop.
|
|
145
|
+
#
|
|
146
|
+
# If the 'agent_builder_enabled' entitlement is disabled, then this is ok
|
|
147
|
+
# because the license key validation would fail and the app would not start.
|
|
148
|
+
if (
|
|
149
|
+
LANGGRAPH_CLOUD_LICENSE_KEY
|
|
150
|
+
and "agent_builder_enabled" in LANGSMITH_LICENSE_REQUIRED_CLAIMS
|
|
151
|
+
):
|
|
152
|
+
logger.info("Skipping metadata loop for self-hosted Agent Builder")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
logger.info("Starting metadata loop", endpoint=LANGCHAIN_METADATA_ENDPOINT)
|
|
138
156
|
|
|
139
157
|
global RUN_COUNTER, NODE_COUNTER, FROM_TIMESTAMP
|
|
140
158
|
base_tags = _ensure_strings(
|
|
@@ -200,7 +218,11 @@ async def metadata_loop() -> None:
|
|
|
200
218
|
body=orjson.dumps(beacon_payload),
|
|
201
219
|
headers={"Content-Type": "application/json"},
|
|
202
220
|
)
|
|
203
|
-
await logger.ainfo(
|
|
221
|
+
await logger.ainfo(
|
|
222
|
+
"Successfully submitted metadata to beacon endpoint",
|
|
223
|
+
n_runs=runs,
|
|
224
|
+
n_nodes=nodes,
|
|
225
|
+
)
|
|
204
226
|
except Exception as e:
|
|
205
227
|
submissions_failed.append("beacon")
|
|
206
228
|
await logger.awarning(
|
|
@@ -221,7 +243,11 @@ async def metadata_loop() -> None:
|
|
|
221
243
|
body=orjson.dumps(langchain_payload),
|
|
222
244
|
headers={"Content-Type": "application/json"},
|
|
223
245
|
)
|
|
224
|
-
logger.info(
|
|
246
|
+
logger.info(
|
|
247
|
+
"Successfully submitted metadata to LangSmith instance",
|
|
248
|
+
n_runs=runs,
|
|
249
|
+
n_nodes=nodes,
|
|
250
|
+
)
|
|
225
251
|
except Exception as e:
|
|
226
252
|
submissions_failed.append("langchain")
|
|
227
253
|
await logger.awarning(
|
|
@@ -25,19 +25,19 @@ class PrivateNetworkMiddleware(BaseHTTPMiddleware):
|
|
|
25
25
|
A web browser determines whether a network is private based on IP address ranges
|
|
26
26
|
and local networking conditions. Typically, it checks:
|
|
27
27
|
|
|
28
|
-
IP Address Range
|
|
28
|
+
IP Address Range - If the website is hosted on an IP within private address
|
|
29
29
|
ranges (RFC 1918):
|
|
30
30
|
|
|
31
|
-
10.0.0.0
|
|
32
|
-
172.16.0.0
|
|
33
|
-
192.168.0.0
|
|
31
|
+
10.0.0.0 - 10.255.255.255
|
|
32
|
+
172.16.0.0 - 172.31.255.255
|
|
33
|
+
192.168.0.0 - 192.168.255.255
|
|
34
34
|
127.0.0.1 (loopback)
|
|
35
|
-
Localhost and Hostname
|
|
35
|
+
Localhost and Hostname - Domains like localhost or .local are assumed to be private.
|
|
36
36
|
|
|
37
|
-
Network Context
|
|
37
|
+
Network Context - The browser may check if the device is connected
|
|
38
38
|
to a local network (e.g., corporate or home Wi-Fi) rather than the public internet.
|
|
39
39
|
|
|
40
|
-
CORS and Private Network Access (PNA)
|
|
40
|
+
CORS and Private Network Access (PNA) - Modern browsers implement restrictions
|
|
41
41
|
where resources on private networks require explicit permission (via CORS headers)
|
|
42
42
|
when accessed from a public site.
|
|
43
43
|
"""
|
langgraph_api/models/run.py
CHANGED
|
@@ -3,15 +3,18 @@ import contextlib
|
|
|
3
3
|
import time
|
|
4
4
|
import uuid
|
|
5
5
|
from collections.abc import Mapping, Sequence
|
|
6
|
-
from typing import Any, NamedTuple, cast
|
|
6
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, cast
|
|
7
7
|
from uuid import UUID
|
|
8
8
|
|
|
9
9
|
import structlog
|
|
10
|
-
from starlette.authentication import BaseUser
|
|
11
10
|
from starlette.exceptions import HTTPException
|
|
12
11
|
from typing_extensions import TypedDict
|
|
13
12
|
|
|
13
|
+
from langgraph_api.encryption.middleware import encrypt_request
|
|
14
|
+
from langgraph_api.feature_flags import FF_USE_CORE_API
|
|
14
15
|
from langgraph_api.graph import GRAPHS, get_assistant_id
|
|
16
|
+
from langgraph_api.grpc.ops import Runs as GrpcRuns
|
|
17
|
+
from langgraph_api.otel_context import inject_current_trace_context
|
|
15
18
|
from langgraph_api.schema import (
|
|
16
19
|
All,
|
|
17
20
|
Config,
|
|
@@ -24,11 +27,17 @@ from langgraph_api.schema import (
|
|
|
24
27
|
RunCommand,
|
|
25
28
|
StreamMode,
|
|
26
29
|
)
|
|
27
|
-
from langgraph_api.utils import AsyncConnectionProto, get_auth_ctx
|
|
30
|
+
from langgraph_api.utils import AsyncConnectionProto, get_auth_ctx, get_user_id
|
|
28
31
|
from langgraph_api.utils.headers import get_configurable_headers
|
|
29
32
|
from langgraph_api.utils.uuids import uuid7
|
|
33
|
+
from langgraph_api.webhook import validate_webhook_url_or_raise
|
|
30
34
|
from langgraph_runtime.ops import Runs
|
|
31
35
|
|
|
36
|
+
CrudRuns = GrpcRuns if FF_USE_CORE_API else Runs
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from starlette.authentication import BaseUser
|
|
40
|
+
|
|
32
41
|
logger = structlog.stdlib.get_logger(__name__)
|
|
33
42
|
|
|
34
43
|
|
|
@@ -82,13 +91,13 @@ class RunCreateDict(TypedDict):
|
|
|
82
91
|
stream_mode: list[StreamMode] | StreamMode
|
|
83
92
|
"""One or more of "values", "messages", "updates" or "events".
|
|
84
93
|
- "values": Stream the thread state any time it changes.
|
|
85
|
-
- "messages": Stream chat messages from thread state and calls to chat models,
|
|
94
|
+
- "messages": Stream chat messages from thread state and calls to chat models,
|
|
86
95
|
token-by-token where possible.
|
|
87
96
|
- "updates": Stream the state updates returned by each node.
|
|
88
97
|
- "events": Stream all events produced by sub-runs (eg. nodes, LLMs, etc.).
|
|
89
98
|
- "custom": Stream custom events produced by your nodes.
|
|
90
|
-
|
|
91
|
-
Note: __interrupt__ events are always included in the updates stream, even when "updates"
|
|
99
|
+
|
|
100
|
+
Note: __interrupt__ events are always included in the updates stream, even when "updates"
|
|
92
101
|
is not explicitly requested, to ensure interrupt events are always visible.
|
|
93
102
|
"""
|
|
94
103
|
stream_subgraphs: bool | None
|
|
@@ -166,18 +175,6 @@ def assign_defaults(
|
|
|
166
175
|
return stream_mode, multitask_strategy, prevent_insert_if_inflight
|
|
167
176
|
|
|
168
177
|
|
|
169
|
-
def get_user_id(user: BaseUser | None) -> str | None:
|
|
170
|
-
if user is None:
|
|
171
|
-
return None
|
|
172
|
-
try:
|
|
173
|
-
return user.identity
|
|
174
|
-
except NotImplementedError:
|
|
175
|
-
try:
|
|
176
|
-
return user.display_name
|
|
177
|
-
except NotImplementedError:
|
|
178
|
-
pass
|
|
179
|
-
|
|
180
|
-
|
|
181
178
|
async def create_valid_run(
|
|
182
179
|
conn: AsyncConnectionProto,
|
|
183
180
|
thread_id: str | None,
|
|
@@ -238,10 +235,12 @@ async def create_valid_run(
|
|
|
238
235
|
if checkpoint := payload.get("checkpoint"):
|
|
239
236
|
configurable.update(checkpoint)
|
|
240
237
|
configurable.update(get_configurable_headers(headers))
|
|
238
|
+
inject_current_trace_context(configurable)
|
|
241
239
|
ctx = get_auth_ctx()
|
|
242
240
|
if ctx:
|
|
243
|
-
user = cast(BaseUser | None, ctx.user)
|
|
241
|
+
user = cast("BaseUser | None", ctx.user)
|
|
244
242
|
user_id = get_user_id(user)
|
|
243
|
+
# Store user as-is; encryption middleware will serialize if needed
|
|
245
244
|
configurable["langgraph_auth_user"] = user
|
|
246
245
|
configurable["langgraph_auth_user_id"] = user_id
|
|
247
246
|
configurable["langgraph_auth_permissions"] = ctx.permissions
|
|
@@ -254,8 +253,10 @@ async def create_valid_run(
|
|
|
254
253
|
configurable["__langsmith_example_id__"] = ls_tracing.get("example_id")
|
|
255
254
|
if request_start_time:
|
|
256
255
|
configurable["__request_start_time_ms__"] = request_start_time
|
|
257
|
-
after_seconds = cast(int, payload.get("after_seconds", 0))
|
|
256
|
+
after_seconds = cast("int", payload.get("after_seconds", 0))
|
|
258
257
|
configurable["__after_seconds__"] = after_seconds
|
|
258
|
+
# Note: encryption context is injected by encrypt_request → encrypt_json_if_needed
|
|
259
|
+
# as the __encryption_context__ marker. Worker reads it before decryption.
|
|
259
260
|
put_time_start = time.time()
|
|
260
261
|
if_not_exists = payload.get("if_not_exists", "reject")
|
|
261
262
|
|
|
@@ -264,14 +265,31 @@ async def create_valid_run(
|
|
|
264
265
|
checkpoint_during = payload.get("checkpoint_during")
|
|
265
266
|
durability = "async" if checkpoint_during in (None, True) else "exit"
|
|
266
267
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
if webhook := payload.get("webhook"):
|
|
269
|
+
await validate_webhook_url_or_raise(str(webhook))
|
|
270
|
+
|
|
271
|
+
# We can't pass payload directly because config and context have
|
|
272
|
+
# been modified above (with auth context, checkpoint info, etc.)
|
|
273
|
+
encrypted = await encrypt_request(
|
|
270
274
|
{
|
|
275
|
+
"metadata": payload.get("metadata"),
|
|
271
276
|
"input": payload.get("input"),
|
|
272
|
-
"command": payload.get("command"),
|
|
273
277
|
"config": config,
|
|
274
278
|
"context": context,
|
|
279
|
+
"command": payload.get("command"),
|
|
280
|
+
},
|
|
281
|
+
"run",
|
|
282
|
+
["metadata", "input", "config", "context", "command"],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
run_coro = CrudRuns.put(
|
|
286
|
+
conn,
|
|
287
|
+
assistant_id,
|
|
288
|
+
{
|
|
289
|
+
"input": encrypted.get("input"),
|
|
290
|
+
"command": encrypted.get("command"),
|
|
291
|
+
"config": encrypted.get("config"),
|
|
292
|
+
"context": encrypted.get("context"),
|
|
275
293
|
"stream_mode": stream_mode,
|
|
276
294
|
"interrupt_before": payload.get("interrupt_before"),
|
|
277
295
|
"interrupt_after": payload.get("interrupt_after"),
|
|
@@ -283,7 +301,7 @@ async def create_valid_run(
|
|
|
283
301
|
"checkpoint_during": payload.get("checkpoint_during", True),
|
|
284
302
|
"durability": durability,
|
|
285
303
|
},
|
|
286
|
-
metadata=
|
|
304
|
+
metadata=encrypted.get("metadata"),
|
|
287
305
|
status="pending",
|
|
288
306
|
user_id=user_id,
|
|
289
307
|
thread_id=thread_id_,
|
|
@@ -332,7 +350,7 @@ async def create_valid_run(
|
|
|
332
350
|
if multitask_strategy in ("interrupt", "rollback") and inflight_runs:
|
|
333
351
|
with contextlib.suppress(HTTPException):
|
|
334
352
|
# if we can't find the inflight runs again, we can proceeed
|
|
335
|
-
await
|
|
353
|
+
await CrudRuns.cancel(
|
|
336
354
|
conn,
|
|
337
355
|
[run["run_id"] for run in inflight_runs],
|
|
338
356
|
thread_id=thread_id_,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""OTEL trace context propagation utilities.
|
|
2
|
+
|
|
3
|
+
Provides helpers for extracting, storing, and restoring W3C Trace Context
|
|
4
|
+
across the API-to-worker boundary in distributed LangGraph deployments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from langgraph_api import __version__, config
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Generator, Mapping
|
|
18
|
+
|
|
19
|
+
from opentelemetry.trace import Tracer
|
|
20
|
+
|
|
21
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Constants for storing trace context in configurable
|
|
24
|
+
OTEL_TRACEPARENT_KEY = "__otel_traceparent__"
|
|
25
|
+
OTEL_TRACESTATE_KEY = "__otel_tracestate__"
|
|
26
|
+
OTEL_TRACER_NAME = "langsmith_agent_server"
|
|
27
|
+
OTEL_RUN_ID_ATTR_NAME = "langsmith.run_id"
|
|
28
|
+
OTEL_THREAD_ID_ATTR_NAME = "langsmith.thread_id"
|
|
29
|
+
|
|
30
|
+
# Cached instances (initialized lazily, once)
|
|
31
|
+
_propagator: Any = None
|
|
32
|
+
_tracer: Any = None
|
|
33
|
+
_otel_available: bool | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_otel_available() -> bool:
|
|
37
|
+
"""Check if OpenTelemetry is available. Cached after first call."""
|
|
38
|
+
global _otel_available
|
|
39
|
+
if _otel_available is None:
|
|
40
|
+
try:
|
|
41
|
+
from opentelemetry import trace # noqa: F401
|
|
42
|
+
from opentelemetry.trace.propagation.tracecontext import (
|
|
43
|
+
TraceContextTextMapPropagator, # noqa: F401
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_otel_available = True
|
|
47
|
+
except ImportError:
|
|
48
|
+
_otel_available = False
|
|
49
|
+
return _otel_available
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_propagator() -> Any:
|
|
53
|
+
"""Get cached W3C TraceContext propagator."""
|
|
54
|
+
global _propagator
|
|
55
|
+
if _propagator is None:
|
|
56
|
+
from opentelemetry.trace.propagation.tracecontext import (
|
|
57
|
+
TraceContextTextMapPropagator,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_propagator = TraceContextTextMapPropagator()
|
|
61
|
+
return _propagator
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_tracer() -> Tracer:
|
|
65
|
+
"""Get cached tracer for worker spans."""
|
|
66
|
+
global _tracer
|
|
67
|
+
if _tracer is None:
|
|
68
|
+
from opentelemetry import trace
|
|
69
|
+
|
|
70
|
+
_tracer = trace.get_tracer(
|
|
71
|
+
OTEL_TRACER_NAME, instrumenting_library_version=__version__
|
|
72
|
+
)
|
|
73
|
+
return _tracer
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_otel_headers_to_configurable(
|
|
77
|
+
headers: Mapping[str, str],
|
|
78
|
+
configurable: dict[str, Any],
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Extract traceparent/tracestate from HTTP headers into configurable dict.
|
|
81
|
+
|
|
82
|
+
Only extracts if OTEL is enabled. No-op otherwise.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
headers: HTTP headers from the incoming request
|
|
86
|
+
configurable: The configurable dict to store trace context in
|
|
87
|
+
"""
|
|
88
|
+
if not config.OTEL_ENABLED:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if traceparent := headers.get("traceparent"):
|
|
92
|
+
configurable[OTEL_TRACEPARENT_KEY] = traceparent
|
|
93
|
+
if tracestate := headers.get("tracestate"):
|
|
94
|
+
configurable[OTEL_TRACESTATE_KEY] = tracestate
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def inject_current_trace_context(configurable: dict[str, Any]) -> None:
|
|
98
|
+
"""Inject current OTEL trace context into configurable for worker propagation.
|
|
99
|
+
|
|
100
|
+
This captures the active span context (e.g., from Starlette auto-instrumentation)
|
|
101
|
+
and stores it in the configurable dict so workers can restore it and create
|
|
102
|
+
child spans under the API request span.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
configurable: The configurable dict to store trace context in
|
|
106
|
+
"""
|
|
107
|
+
if not config.OTEL_ENABLED or not _check_otel_available():
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
from opentelemetry import trace
|
|
112
|
+
|
|
113
|
+
span = trace.get_current_span()
|
|
114
|
+
if not span.is_recording():
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
carrier: dict[str, str] = {}
|
|
118
|
+
_get_propagator().inject(carrier)
|
|
119
|
+
|
|
120
|
+
if traceparent := carrier.get("traceparent"):
|
|
121
|
+
configurable[OTEL_TRACEPARENT_KEY] = traceparent
|
|
122
|
+
if tracestate := carrier.get("tracestate"):
|
|
123
|
+
configurable[OTEL_TRACESTATE_KEY] = tracestate
|
|
124
|
+
except Exception:
|
|
125
|
+
# Never fail - tracing issues shouldn't break functionality
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@contextmanager
|
|
130
|
+
def restore_otel_trace_context(
|
|
131
|
+
configurable: dict[str, Any],
|
|
132
|
+
run_id: str | None = None,
|
|
133
|
+
thread_id: str | None = None,
|
|
134
|
+
) -> Generator[None, None, None]:
|
|
135
|
+
"""Restore OTEL trace context and create child span for worker execution.
|
|
136
|
+
|
|
137
|
+
Creates a child span under the original API request span, ensuring
|
|
138
|
+
distributed traces are connected across the API-to-worker boundary.
|
|
139
|
+
|
|
140
|
+
Yields:
|
|
141
|
+
None - execution continues within the restored trace context
|
|
142
|
+
|
|
143
|
+
Note:
|
|
144
|
+
- No-ops if OTEL is disabled or unavailable
|
|
145
|
+
- Never raises - tracing failures won't break run execution
|
|
146
|
+
"""
|
|
147
|
+
if not config.OTEL_ENABLED or not _check_otel_available():
|
|
148
|
+
yield
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
traceparent = configurable.get(OTEL_TRACEPARENT_KEY)
|
|
152
|
+
if not traceparent:
|
|
153
|
+
yield
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
from opentelemetry import trace
|
|
158
|
+
|
|
159
|
+
# Build carrier dict for W3C propagator
|
|
160
|
+
carrier: dict[str, str] = {"traceparent": traceparent}
|
|
161
|
+
if tracestate := configurable.get(OTEL_TRACESTATE_KEY):
|
|
162
|
+
carrier["tracestate"] = tracestate
|
|
163
|
+
|
|
164
|
+
# Extract context from carrier
|
|
165
|
+
ctx = _get_propagator().extract(carrier=carrier)
|
|
166
|
+
|
|
167
|
+
with _get_tracer().start_as_current_span(
|
|
168
|
+
"worker.stream_run",
|
|
169
|
+
context=ctx,
|
|
170
|
+
kind=trace.SpanKind.CONSUMER,
|
|
171
|
+
) as span:
|
|
172
|
+
if run_id:
|
|
173
|
+
span.set_attribute(OTEL_RUN_ID_ATTR_NAME, run_id)
|
|
174
|
+
if thread_id:
|
|
175
|
+
span.set_attribute(OTEL_THREAD_ID_ATTR_NAME, thread_id)
|
|
176
|
+
|
|
177
|
+
yield
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.debug("Failed to restore OTEL trace context", exc_info=True)
|
|
180
|
+
yield
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def inject_otel_headers() -> dict[str, str]:
|
|
184
|
+
"""Inject current trace context into headers for outgoing HTTP requests.
|
|
185
|
+
|
|
186
|
+
Used to propagate trace context to webhooks.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Dict with traceparent/tracestate headers if in active trace, else empty.
|
|
190
|
+
"""
|
|
191
|
+
if not config.OTEL_ENABLED or not _check_otel_available():
|
|
192
|
+
return {}
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
from opentelemetry import trace
|
|
196
|
+
|
|
197
|
+
span = trace.get_current_span()
|
|
198
|
+
if not span.is_recording():
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
carrier: dict[str, str] = {}
|
|
202
|
+
_get_propagator().inject(carrier)
|
|
203
|
+
return carrier
|
|
204
|
+
except Exception:
|
|
205
|
+
return {}
|
langgraph_api/patch.py
CHANGED
|
@@ -14,7 +14,7 @@ to recognize bytearrays and memoryviews as bytes-like objects.
|
|
|
14
14
|
def Response_render(self, content: Any) -> bytes:
|
|
15
15
|
if content is None:
|
|
16
16
|
return b""
|
|
17
|
-
if isinstance(content, (bytes, bytearray, memoryview)):
|
|
17
|
+
if isinstance(content, (bytes, bytearray, memoryview)):
|
|
18
18
|
return content
|
|
19
19
|
return content.encode(self.charset) # type: ignore
|
|
20
20
|
|
|
@@ -34,7 +34,7 @@ async def StreamingResponse_stream_response(self, send: Send) -> None:
|
|
|
34
34
|
chunk = chunk.buf
|
|
35
35
|
if isinstance(chunk, dict):
|
|
36
36
|
chunk = json_dumpb(chunk)
|
|
37
|
-
if not isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
37
|
+
if not isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
38
38
|
chunk = chunk.encode(self.charset)
|
|
39
39
|
await send({"type": "http.response.body", "body": chunk, "more_body": True})
|
|
40
40
|
|
|
@@ -1,29 +1,26 @@
|
|
|
1
|
-
# ruff: noqa: E402
|
|
2
1
|
import os
|
|
3
2
|
|
|
4
|
-
from langgraph_api.api.meta import METRICS_FORMATS
|
|
5
|
-
|
|
6
3
|
if not (
|
|
7
4
|
(disable_truststore := os.getenv("DISABLE_TRUSTSTORE"))
|
|
8
5
|
and disable_truststore.lower() == "true"
|
|
9
6
|
):
|
|
10
|
-
import truststore
|
|
7
|
+
import truststore
|
|
11
8
|
|
|
12
|
-
truststore.inject_into_ssl()
|
|
9
|
+
truststore.inject_into_ssl()
|
|
13
10
|
|
|
14
11
|
import asyncio
|
|
12
|
+
import functools
|
|
15
13
|
import json
|
|
16
14
|
import logging.config
|
|
17
15
|
import pathlib
|
|
18
16
|
import signal
|
|
19
17
|
import socket
|
|
20
|
-
from contextlib import asynccontextmanager
|
|
21
18
|
|
|
22
19
|
import structlog
|
|
23
20
|
|
|
24
21
|
from langgraph_api.utils.errors import GraphLoadError, HealthServerStartupError
|
|
25
22
|
from langgraph_runtime import lifespan
|
|
26
|
-
from langgraph_runtime.database import pool_stats
|
|
23
|
+
from langgraph_runtime.database import healthcheck, pool_stats
|
|
27
24
|
from langgraph_runtime.metrics import get_metrics
|
|
28
25
|
|
|
29
26
|
logger = structlog.stdlib.get_logger(__name__)
|
|
@@ -43,12 +40,17 @@ async def health_and_metrics_server():
|
|
|
43
40
|
from starlette.applications import Starlette
|
|
44
41
|
from starlette.requests import Request
|
|
45
42
|
from starlette.responses import JSONResponse, PlainTextResponse
|
|
46
|
-
from starlette.routing import Route
|
|
43
|
+
from starlette.routing import Mount, Route
|
|
44
|
+
|
|
45
|
+
from langgraph_api import config as lc_config
|
|
46
|
+
from langgraph_api.api.meta import METRICS_FORMATS
|
|
47
47
|
|
|
48
48
|
port = int(os.getenv("PORT", "8080"))
|
|
49
49
|
host = os.getenv("LANGGRAPH_SERVER_HOST", "0.0.0.0")
|
|
50
50
|
|
|
51
51
|
async def health_endpoint(request):
|
|
52
|
+
# if db or redis is not healthy, this will raise an exception
|
|
53
|
+
await healthcheck()
|
|
52
54
|
return JSONResponse({"status": "ok"})
|
|
53
55
|
|
|
54
56
|
async def metrics_endpoint(request: Request):
|
|
@@ -100,12 +102,17 @@ async def health_and_metrics_server():
|
|
|
100
102
|
media_type="text/plain; version=0.0.4; charset=utf-8",
|
|
101
103
|
)
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
routes = [
|
|
106
|
+
Route("/ok", health_endpoint),
|
|
107
|
+
Route("/metrics", metrics_endpoint),
|
|
108
|
+
]
|
|
109
|
+
app = Starlette(routes=routes)
|
|
110
|
+
if lc_config.MOUNT_PREFIX:
|
|
111
|
+
app = Starlette(
|
|
112
|
+
routes=[*routes, Mount(lc_config.MOUNT_PREFIX, app=app)],
|
|
113
|
+
lifespan=app.router.lifespan_context,
|
|
114
|
+
exception_handlers=app.exception_handlers,
|
|
115
|
+
)
|
|
109
116
|
|
|
110
117
|
try:
|
|
111
118
|
_ensure_port_available(host, port)
|
|
@@ -163,33 +170,25 @@ async def entrypoint(
|
|
|
163
170
|
cancel_event: asyncio.Event | None = None,
|
|
164
171
|
):
|
|
165
172
|
from langgraph_api import logging as lg_logging
|
|
173
|
+
from langgraph_api import timing
|
|
166
174
|
from langgraph_api.api import user_router
|
|
175
|
+
from langgraph_api.server import app
|
|
167
176
|
|
|
168
177
|
lg_logging.set_logging_context({"entrypoint": entrypoint_name})
|
|
169
178
|
tasks: set[asyncio.Task] = set()
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
app, with_cron_scheduler=False, grpc_port=None, taskset=None
|
|
176
|
-
):
|
|
177
|
-
async with lifespan.lifespan(
|
|
178
|
-
app,
|
|
179
|
-
with_cron_scheduler=with_cron_scheduler,
|
|
179
|
+
user_lifespan = None if user_router is None else user_router.router.lifespan_context
|
|
180
|
+
wrapped_lifespan = timing.combine_lifespans(
|
|
181
|
+
functools.partial(
|
|
182
|
+
lifespan.lifespan,
|
|
183
|
+
with_cron_scheduler=False,
|
|
180
184
|
grpc_port=grpc_port,
|
|
181
|
-
taskset=
|
|
185
|
+
taskset=tasks,
|
|
182
186
|
cancel_event=cancel_event,
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
yield
|
|
189
|
-
|
|
190
|
-
async with combined_lifespan(
|
|
191
|
-
None, with_cron_scheduler=False, grpc_port=grpc_port, taskset=tasks
|
|
192
|
-
):
|
|
187
|
+
),
|
|
188
|
+
user_lifespan,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async with wrapped_lifespan(app):
|
|
193
192
|
tasks.add(asyncio.create_task(health_and_metrics_server()))
|
|
194
193
|
await asyncio.gather(*tasks)
|
|
195
194
|
|
langgraph_api/route.py
CHANGED
|
@@ -4,6 +4,7 @@ import typing
|
|
|
4
4
|
|
|
5
5
|
import jsonschema_rs
|
|
6
6
|
import orjson
|
|
7
|
+
import structlog
|
|
7
8
|
from starlette._exception_handler import wrap_app_handling_exceptions
|
|
8
9
|
from starlette._utils import is_async_callable
|
|
9
10
|
from starlette.concurrency import run_in_threadpool
|
|
@@ -18,6 +19,7 @@ from langgraph_api import config
|
|
|
18
19
|
from langgraph_api.serde import json_dumpb
|
|
19
20
|
from langgraph_api.utils import get_auth_ctx, with_user
|
|
20
21
|
|
|
22
|
+
logger = structlog.getLogger(__name__)
|
|
21
23
|
SchemaType = (
|
|
22
24
|
jsonschema_rs.Draft4Validator
|
|
23
25
|
| jsonschema_rs.Draft6Validator
|
|
@@ -44,7 +46,7 @@ def api_request_response(
|
|
|
44
46
|
response: ASGIApp = await func(request)
|
|
45
47
|
else:
|
|
46
48
|
response = await run_in_threadpool(
|
|
47
|
-
typing.cast(typing.Callable[[Request], ASGIApp], func), request
|
|
49
|
+
typing.cast("typing.Callable[[Request], ASGIApp]", func), request
|
|
48
50
|
)
|
|
49
51
|
await response(scope, receive, send)
|
|
50
52
|
|
|
@@ -145,6 +147,8 @@ class ApiRoute(Route):
|
|
|
145
147
|
|
|
146
148
|
scope["route"] = self.path
|
|
147
149
|
set_logging_context({"path": self.path, "method": scope.get("method")})
|
|
150
|
+
route_pattern = f"{scope.get('root_path', '')}{self.path}"
|
|
151
|
+
_name_otel_span(scope, route_pattern)
|
|
148
152
|
ctx = get_auth_ctx()
|
|
149
153
|
if ctx:
|
|
150
154
|
user, auth = ctx.user, ctx.permissions
|
|
@@ -152,3 +156,31 @@ class ApiRoute(Route):
|
|
|
152
156
|
user, auth = scope.get("user"), scope.get("auth")
|
|
153
157
|
async with with_user(user, auth):
|
|
154
158
|
return await super().handle(scope, receive, send)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _name_otel_span(scope: Scope, route_pattern: str):
|
|
162
|
+
"""Best-effort rename of the active OTEL server span to include the route.
|
|
163
|
+
|
|
164
|
+
- No-ops if OTEL is disabled or OTEL libs are unavailable.
|
|
165
|
+
- Sets span name to "METHOD /templated/path" and attaches http.route.
|
|
166
|
+
- Never raises; safe for hot path usage.
|
|
167
|
+
"""
|
|
168
|
+
if not config.OTEL_ENABLED:
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
from opentelemetry.trace import get_current_span
|
|
172
|
+
|
|
173
|
+
span = get_current_span()
|
|
174
|
+
if span.is_recording():
|
|
175
|
+
method = scope.get("method", "") or ""
|
|
176
|
+
try:
|
|
177
|
+
span.update_name(f"{method} {route_pattern}")
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.error("Failed to update OTEL span name", exc_info=True)
|
|
180
|
+
pass
|
|
181
|
+
try:
|
|
182
|
+
span.set_attribute("http.route", route_pattern)
|
|
183
|
+
except Exception:
|
|
184
|
+
logger.error("Failed to update OTEL span attributes", exc_info=True)
|
|
185
|
+
except Exception:
|
|
186
|
+
logger.error("Failed to update OTEL span", exc_info=True)
|