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.
Files changed (122) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +93 -27
  3. langgraph_api/api/a2a.py +36 -32
  4. langgraph_api/api/assistants.py +114 -26
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +15 -2
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +114 -57
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +133 -10
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/auth/custom.py +23 -13
  14. langgraph_api/cli.py +86 -41
  15. langgraph_api/command.py +2 -2
  16. langgraph_api/config/__init__.py +532 -0
  17. langgraph_api/config/_parse.py +58 -0
  18. langgraph_api/config/schemas.py +431 -0
  19. langgraph_api/cron_scheduler.py +17 -1
  20. langgraph_api/encryption/__init__.py +15 -0
  21. langgraph_api/encryption/aes_json.py +158 -0
  22. langgraph_api/encryption/context.py +35 -0
  23. langgraph_api/encryption/custom.py +280 -0
  24. langgraph_api/encryption/middleware.py +632 -0
  25. langgraph_api/encryption/shared.py +63 -0
  26. langgraph_api/errors.py +12 -1
  27. langgraph_api/executor_entrypoint.py +11 -6
  28. langgraph_api/feature_flags.py +19 -0
  29. langgraph_api/graph.py +163 -64
  30. langgraph_api/{grpc_ops → grpc}/client.py +142 -12
  31. langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
  32. langgraph_api/grpc/generated/__init__.py +29 -0
  33. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  34. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  35. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  36. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  37. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
  38. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
  39. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  40. langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
  41. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  42. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  43. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  44. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  45. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  46. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  47. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  48. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  49. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  50. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  51. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  52. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  53. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  54. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  55. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  56. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  57. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  58. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  59. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  60. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  61. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  62. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  63. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  64. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  65. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  66. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  67. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  68. langgraph_api/grpc/ops/__init__.py +370 -0
  69. langgraph_api/grpc/ops/assistants.py +424 -0
  70. langgraph_api/grpc/ops/runs.py +792 -0
  71. langgraph_api/grpc/ops/threads.py +1013 -0
  72. langgraph_api/http.py +16 -5
  73. langgraph_api/js/client.mts +1 -4
  74. langgraph_api/js/package.json +28 -27
  75. langgraph_api/js/remote.py +39 -17
  76. langgraph_api/js/sse.py +2 -2
  77. langgraph_api/js/ui.py +1 -1
  78. langgraph_api/js/yarn.lock +1139 -869
  79. langgraph_api/metadata.py +29 -3
  80. langgraph_api/middleware/http_logger.py +1 -1
  81. langgraph_api/middleware/private_network.py +7 -7
  82. langgraph_api/models/run.py +44 -26
  83. langgraph_api/otel_context.py +205 -0
  84. langgraph_api/patch.py +2 -2
  85. langgraph_api/queue_entrypoint.py +34 -35
  86. langgraph_api/route.py +33 -1
  87. langgraph_api/schema.py +84 -9
  88. langgraph_api/self_hosted_logs.py +2 -2
  89. langgraph_api/self_hosted_metrics.py +73 -3
  90. langgraph_api/serde.py +16 -4
  91. langgraph_api/server.py +33 -31
  92. langgraph_api/state.py +3 -2
  93. langgraph_api/store.py +25 -16
  94. langgraph_api/stream.py +20 -16
  95. langgraph_api/thread_ttl.py +28 -13
  96. langgraph_api/timing/__init__.py +25 -0
  97. langgraph_api/timing/profiler.py +200 -0
  98. langgraph_api/timing/timer.py +318 -0
  99. langgraph_api/utils/__init__.py +53 -8
  100. langgraph_api/utils/config.py +2 -1
  101. langgraph_api/utils/future.py +10 -6
  102. langgraph_api/utils/uuids.py +29 -62
  103. langgraph_api/validation.py +6 -0
  104. langgraph_api/webhook.py +120 -6
  105. langgraph_api/worker.py +54 -24
  106. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
  107. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  108. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  109. langgraph_runtime/__init__.py +1 -0
  110. langgraph_runtime/routes.py +11 -0
  111. logging.json +1 -3
  112. openapi.json +635 -537
  113. langgraph_api/config.py +0 -523
  114. langgraph_api/grpc_ops/generated/__init__.py +0 -5
  115. langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
  116. langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
  117. langgraph_api/grpc_ops/ops.py +0 -1045
  118. langgraph_api-0.5.4.dist-info/RECORD +0 -121
  119. /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
  120. /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
  121. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  122. {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
- logger.info("Starting metadata loop")
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("Successfully submitted metadata to beacon endpoint")
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("Successfully submitted metadata to LangSmith instance")
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(
@@ -97,7 +97,7 @@ class AccessLoggerMiddleware:
97
97
  path=path,
98
98
  status=status,
99
99
  latency_ms=latency,
100
- route=route,
100
+ route=str(route),
101
101
  path_params=scope.get("path_params"),
102
102
  query_string=qs.decode() if qs else "",
103
103
  proto=scope.get("http_version"),
@@ -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 If the website is hosted on an IP within private address
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 10.255.255.255
32
- 172.16.0.0 172.31.255.255
33
- 192.168.0.0 192.168.255.255
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 Domains like localhost or .local are assumed to be private.
35
+ Localhost and Hostname - Domains like localhost or .local are assumed to be private.
36
36
 
37
- Network Context The browser may check if the device is connected
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) Modern browsers implement restrictions
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
  """
@@ -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
- run_coro = Runs.put(
268
- conn,
269
- assistant_id,
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=payload.get("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 Runs.cancel(
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)): # noqa: UP038
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)): # noqa: UP038
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 # noqa: F401
7
+ import truststore
11
8
 
12
- truststore.inject_into_ssl() # noqa: F401
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
- app = Starlette(
104
- routes=[
105
- Route("/ok", health_endpoint),
106
- Route("/metrics", metrics_endpoint),
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
- original_lifespan = user_router.router.lifespan_context if user_router else None
172
-
173
- @asynccontextmanager
174
- async def combined_lifespan(
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=taskset,
185
+ taskset=tasks,
182
186
  cancel_event=cancel_event,
183
- ):
184
- if original_lifespan:
185
- async with original_lifespan(app):
186
- yield
187
- else:
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)