feat: add request-id for steps [LET-6587] (#7349)
* feat: add request-id for steps * order revisions correctly * stage publish api
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
"""add request_id to steps table
|
||||||
|
|
||||||
|
Revision ID: ee2b43eea55e
|
||||||
|
Revises: 39577145c45d
|
||||||
|
Create Date: 2025-12-17 13:48:08.642245
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "ee2b43eea55e"
|
||||||
|
down_revision: Union[str, None] = "39577145c45d"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("steps", sa.Column("request_id", sa.String(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("steps", "request_id")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -36560,6 +36560,18 @@
|
|||||||
"title": "Trace Id",
|
"title": "Trace Id",
|
||||||
"description": "The trace id of the agent step."
|
"description": "The trace id of the agent step."
|
||||||
},
|
},
|
||||||
|
"request_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Request Id",
|
||||||
|
"description": "The API request log ID from cloud-api for correlating steps with API requests."
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Message"
|
"$ref": "#/components/schemas/Message"
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class Step(SqlalchemyBase, ProjectMixin):
|
|||||||
tags: Mapped[Optional[List]] = mapped_column(JSON, doc="Metadata tags.")
|
tags: Mapped[Optional[List]] = mapped_column(JSON, doc="Metadata tags.")
|
||||||
tid: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="Transaction ID that processed the step.")
|
tid: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="Transaction ID that processed the step.")
|
||||||
trace_id: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="The trace id of the agent step.")
|
trace_id: Mapped[Optional[str]] = mapped_column(None, nullable=True, doc="The trace id of the agent step.")
|
||||||
|
request_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
None, nullable=True, doc="The API request log ID from cloud-api for correlating steps with API requests."
|
||||||
|
)
|
||||||
feedback: Mapped[Optional[str]] = mapped_column(
|
feedback: Mapped[Optional[str]] = mapped_column(
|
||||||
None, nullable=True, doc="The feedback for this step. Must be either 'positive' or 'negative'."
|
None, nullable=True, doc="The feedback for this step. Must be either 'positive' or 'negative'."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class Step(StepBase):
|
|||||||
tags: List[str] = Field([], description="Metadata tags.")
|
tags: List[str] = Field([], description="Metadata tags.")
|
||||||
tid: Optional[str] = Field(None, description="The unique identifier of the transaction that processed this step.")
|
tid: Optional[str] = Field(None, description="The unique identifier of the transaction that processed this step.")
|
||||||
trace_id: Optional[str] = Field(None, description="The trace id of the agent step.")
|
trace_id: Optional[str] = Field(None, description="The trace id of the agent step.")
|
||||||
|
request_id: Optional[str] = Field(None, description="The API request log ID from cloud-api for correlating steps with API requests.")
|
||||||
messages: List[Message] = Field(
|
messages: List[Message] = Field(
|
||||||
[],
|
[],
|
||||||
description="The messages generated during this step. Deprecated: use `GET /v1/steps/{step_id}/messages` endpoint instead",
|
description="The messages generated during this step. Deprecated: use `GET /v1/steps/{step_id}/messages` endpoint instead",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ from letta.server.global_exception_handler import setup_global_exception_handler
|
|||||||
# NOTE(charles): these are extra routes that are not part of v1 but we still need to mount to pass tests
|
# NOTE(charles): these are extra routes that are not part of v1 but we still need to mount to pass tests
|
||||||
from letta.server.rest_api.auth.index import setup_auth_router # TODO: probably remove right?
|
from letta.server.rest_api.auth.index import setup_auth_router # TODO: probably remove right?
|
||||||
from letta.server.rest_api.interface import StreamingServerInterface
|
from letta.server.rest_api.interface import StreamingServerInterface
|
||||||
from letta.server.rest_api.middleware import CheckPasswordMiddleware, LoggingMiddleware
|
from letta.server.rest_api.middleware import CheckPasswordMiddleware, LoggingMiddleware, RequestIdMiddleware
|
||||||
from letta.server.rest_api.routers.v1 import ROUTERS as v1_routes
|
from letta.server.rest_api.routers.v1 import ROUTERS as v1_routes
|
||||||
from letta.server.rest_api.routers.v1.organizations import router as organizations_router
|
from letta.server.rest_api.routers.v1.organizations import router as organizations_router
|
||||||
from letta.server.rest_api.routers.v1.users import router as users_router # TODO: decide on admin
|
from letta.server.rest_api.routers.v1.users import router as users_router # TODO: decide on admin
|
||||||
@@ -591,6 +591,10 @@ def create_application() -> "FastAPI":
|
|||||||
# Add unified logging middleware - enriches log context and logs exceptions
|
# Add unified logging middleware - enriches log context and logs exceptions
|
||||||
app.add_middleware(LoggingMiddleware)
|
app.add_middleware(LoggingMiddleware)
|
||||||
|
|
||||||
|
# Add request ID middleware - extracts x-api-request-log-id header and sets it in contextvar
|
||||||
|
# This is a pure ASGI middleware to properly propagate contextvars to streaming responses
|
||||||
|
app.add_middleware(RequestIdMiddleware)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from letta.server.rest_api.middleware.check_password import CheckPasswordMiddleware
|
from letta.server.rest_api.middleware.check_password import CheckPasswordMiddleware
|
||||||
from letta.server.rest_api.middleware.logging import LoggingMiddleware
|
from letta.server.rest_api.middleware.logging import LoggingMiddleware
|
||||||
|
from letta.server.rest_api.middleware.request_id import RequestIdMiddleware
|
||||||
|
|
||||||
__all__ = ["CheckPasswordMiddleware", "LoggingMiddleware"]
|
__all__ = ["CheckPasswordMiddleware", "LoggingMiddleware", "RequestIdMiddleware"]
|
||||||
|
|||||||
63
letta/server/rest_api/middleware/request_id.py
Normal file
63
letta/server/rest_api/middleware/request_id.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Middleware for extracting and propagating API request IDs from cloud-api.
|
||||||
|
|
||||||
|
Uses a pure ASGI middleware pattern to properly propagate the request_id
|
||||||
|
to streaming responses. BaseHTTPMiddleware has a known limitation where
|
||||||
|
contextvars are not propagated to streaming response generators.
|
||||||
|
See: https://github.com/encode/starlette/discussions/1729
|
||||||
|
|
||||||
|
This middleware:
|
||||||
|
1. Extracts the x-api-request-log-id header from cloud-api
|
||||||
|
2. Sets it in the contextvar (for non-streaming code)
|
||||||
|
3. Stores it in request.state (for streaming responses where contextvars don't propagate)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
# Contextvar for storing the request ID across async boundaries
|
||||||
|
request_id_var: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> Optional[str]:
|
||||||
|
"""Get the request ID from the current context."""
|
||||||
|
return request_id_var.get()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdMiddleware:
|
||||||
|
"""
|
||||||
|
Pure ASGI middleware that extracts and propagates the API request ID.
|
||||||
|
|
||||||
|
The request ID comes from cloud-api via the x-api-request-log-id header
|
||||||
|
and is used to correlate steps with API request logs.
|
||||||
|
|
||||||
|
This middleware stores the request_id in:
|
||||||
|
- The request_id_var contextvar (works for non-streaming responses)
|
||||||
|
- request.state.request_id (works for streaming responses where contextvars may not propagate)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a Request object for easier header access
|
||||||
|
request = Request(scope)
|
||||||
|
|
||||||
|
# Extract request_id from header
|
||||||
|
request_id = request.headers.get("x-api-request-log-id")
|
||||||
|
|
||||||
|
# Set in contextvar (for non-streaming code paths)
|
||||||
|
request_id_var.set(request_id)
|
||||||
|
|
||||||
|
# Also store in request.state for streaming responses where contextvars don't propagate
|
||||||
|
# This is accessible via request.state.request_id throughout the request lifecycle
|
||||||
|
request.state.request_id = request_id
|
||||||
|
|
||||||
|
await self.app(scope, receive, send)
|
||||||
@@ -21,6 +21,7 @@ from letta.schemas.step import Step as PydanticStep
|
|||||||
from letta.schemas.step_metrics import StepMetrics as PydanticStepMetrics
|
from letta.schemas.step_metrics import StepMetrics as PydanticStepMetrics
|
||||||
from letta.schemas.user import User as PydanticUser
|
from letta.schemas.user import User as PydanticUser
|
||||||
from letta.server.db import db_registry
|
from letta.server.db import db_registry
|
||||||
|
from letta.server.rest_api.middleware.request_id import get_request_id
|
||||||
from letta.services.webhook_service import WebhookService
|
from letta.services.webhook_service import WebhookService
|
||||||
from letta.utils import enforce_types
|
from letta.utils import enforce_types
|
||||||
from letta.validators import raise_on_invalid_id
|
from letta.validators import raise_on_invalid_id
|
||||||
@@ -123,6 +124,7 @@ class StepManager:
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"tid": None,
|
"tid": None,
|
||||||
"trace_id": get_trace_id(), # Get the current trace ID
|
"trace_id": get_trace_id(), # Get the current trace ID
|
||||||
|
"request_id": get_request_id(), # Get the API request log ID from cloud-api
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"status": status if status else StepStatus.PENDING,
|
"status": status if status else StepStatus.PENDING,
|
||||||
"error_type": error_type,
|
"error_type": error_type,
|
||||||
@@ -182,6 +184,7 @@ class StepManager:
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"tid": None,
|
"tid": None,
|
||||||
"trace_id": get_trace_id(), # Get the current trace ID
|
"trace_id": get_trace_id(), # Get the current trace ID
|
||||||
|
"request_id": get_request_id(), # Get the API request log ID from cloud-api
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"status": status if status else StepStatus.PENDING,
|
"status": status if status else StepStatus.PENDING,
|
||||||
"error_type": error_type,
|
"error_type": error_type,
|
||||||
|
|||||||
Reference in New Issue
Block a user