Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/backend/celery_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@
app.config_from_object("backend.celery_config.CeleryConfig")
app.autodiscover_tasks()

# Register signal handlers (e.g. request_id propagation onto published tasks).
# Importing the module connects the @before_task_publish handler.
import backend.celery_signals # noqa: E402, F401

logger.debug(f"Celery Configuration:\n {pformat(app.conf.table(with_defaults=True))}")
93 changes: 93 additions & 0 deletions backend/backend/celery_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Celery signal handlers for the backend (producer side).

Propagates the HTTP ``request_id`` (correlation ID assigned by
``CustomRequestIDMiddleware``) onto every published Celery task so that worker
logs can be correlated back to the originating request.

The value is placed in the task message headers under ``request_id``. Workers
read it from ``task.request`` in ``task_prerun`` and bind it onto their log
context -- see ``workers/shared/infrastructure/logging/logger.py``. Using the
``before_task_publish`` signal means this works for *every* ``send_task`` /
``.delay`` / ``.apply_async`` call with no per-call-site changes.
"""

import logging

from account_v2.constants import Common
from celery.signals import before_task_publish, task_postrun, task_prerun
from log_request_id import local as log_request_id_local
from utils.local_context import StateStore

logger = logging.getLogger(__name__)


@before_task_publish.connect
def propagate_request_id(headers=None, **kwargs):
"""Inject the current request_id into the outgoing task's message headers.

Fires in the producer thread (the web request thread for API-triggered
tasks), where ``StateStore`` still holds the request_id set by
``CustomRequestIDMiddleware``. No-ops when there is no request_id in scope
(e.g. beat-scheduled publishes), leaving the worker to fall back to its
own correlation id (execution_id / task_id).
"""
if headers is None:
return
try:
request_id = StateStore.get(Common.REQUEST_ID)
except Exception:
# StateStore can raise if CONCURRENCY_MODE is misconfigured; never let
# correlation plumbing break task publishing.
logger.debug("Unable to read request_id from StateStore", exc_info=True)
return
if request_id and not headers.get(Common.REQUEST_ID):
headers[Common.REQUEST_ID] = request_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header is injected for the Django Celery workers too, but nothing there consumes it — and those workers don't re-propagate.

The workers/ package gets its receiving side from _bind_task_context / _propagate_request_id_on_publish, but the backend Celery app has no task_prerun equivalent: its logging uses log_request_id.filters.RequestIDFilter, which reads log_request_id.local.request_id — only ever populated by the HTTP middleware. So for -A backend worker services (e.g. worker-metrics on dashboard_metric_events in docker/docker-compose.yaml:49, plus the celery / celery_api_deployments / celery_periodic_logs queues) the injected header is carried and then dropped: those task logs still show request_id:-.

Second-order effect: since StateStore is only written by CustomAuthMiddleware on an HTTP request, this handler is a no-op inside those worker processes, so any task they publish downstream loses the id too — the chain breaks at the first backend-worker hop.

Also a doc nit on the docstring above: StateStore[request_id] is populated by CustomAuthMiddleware (backend/account_v2/custom_auth_middleware.py:26), not by CustomRequestIDMiddleware (which only sets request.id). Worth correcting since the distinction matters for which requests actually have an id in scope.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713f9a4. Added task_prerun/task_postrun handlers in celery_signals.py that bind the injected header onto log_request_id.local (so the backend RequestIDFilter emits it) and onto StateStore (so a task the backend worker itself publishes re-propagates). No longer a no-op on that side.



def _request_id_from_task(task) -> str | None:
"""Read a propagated request_id off a Celery task's message context."""
request = getattr(task, "request", None)
if request is None:
return None
request_id = getattr(request, Common.REQUEST_ID, None)
if not request_id:
task_headers = getattr(request, "headers", None)
if isinstance(task_headers, dict):
request_id = task_headers.get(Common.REQUEST_ID)
return request_id or None


@task_prerun.connect
def bind_request_id(task=None, **kwargs):
"""Bind the propagated request_id for tasks executed by the backend's OWN
Celery workers (beat, dashboard-metric tasks, etc.).

The separate ``workers/`` fleet has its own ``task_prerun`` reader; the
backend Celery app previously injected the header (``propagate_request_id``)
but never consumed it, so backend-executed tasks logged ``request_id:-``.
Binding it onto ``log_request_id``'s thread-local makes
``log_request_id.filters.RequestIDFilter`` emit it, and onto ``StateStore``
so any task this worker itself publishes re-propagates it.
"""
request_id = _request_id_from_task(task)
if not request_id:
return
log_request_id_local.request_id = request_id
try:
StateStore.set(Common.REQUEST_ID, request_id)
except Exception:
logger.debug("Unable to set request_id on StateStore", exc_info=True)


@task_postrun.connect
def clear_request_id(**kwargs):
"""Clear the task-scoped request_id bound in ``bind_request_id``."""
if hasattr(log_request_id_local, "request_id"):
try:
del log_request_id_local.request_id
except AttributeError:
pass
try:
StateStore.clear(Common.REQUEST_ID)
except Exception:
logger.debug("Unable to clear request_id from StateStore", exc_info=True)
6 changes: 5 additions & 1 deletion backend/backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,11 @@ def get_required_setting(setting_key: str, default: str | None = None) -> str |
CORS_ALLOW_ALL_ORIGINS = False

# Request ID middleware settings
LOG_REQUEST_ID_HEADER = "X-Request-ID"
# django-log-request-id resolves this via request.META.get(...), where WSGI exposes
# the incoming "X-Request-ID" header as the HTTP_-prefixed key HTTP_X_REQUEST_ID.
# It MUST be the META key, not the raw header name, or the incoming id is never read
# and a fresh one is minted on every request (breaking client/worker correlation).
LOG_REQUEST_ID_HEADER = "HTTP_X_REQUEST_ID"
REQUEST_ID_RESPONSE_HEADER = "X-Request-ID"
GENERATE_REQUEST_ID_IF_NOT_IN_HEADER = True
NO_REQUEST_ID = "-"
Expand Down
10 changes: 7 additions & 3 deletions unstract/core/src/unstract/core/flask/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ def setup_logging(log_level: int):
"disable_existing_loggers": False,
"formatters": {
"default": {
# Canonical format shared with the Django backend (``enriched``),
# the workers (``WorkerLogger``) and the x2text-service so a single
# gcloud query parses request_id/trace_id/span_id uniformly.
"format": (
"%(levelname)s : [%(asctime)s]"
"{pid:%(process)d tid:%(thread)d request_id:%(request_id)s "
+ "trace_id:%(otelTraceID)s span_id:%(otelSpanID)s "
+ "%(name)s}:- %(message)s"
"{module:%(module)s process:%(process)d thread:%(thread)d "
"request_id:%(request_id)s "
"trace_id:%(otelTraceID)s span_id:%(otelSpanID)s}"
" :- %(message)s"
),
},
},
Expand Down
10 changes: 10 additions & 0 deletions unstract/core/src/unstract/core/flask/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ def register_request_id_middleware(app: Flask):
@app.before_request
def assign_request_id():
g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))

@app.after_request
def echo_request_id(response):
# Echo the id back so a caller that did not supply one can learn the
# value this service minted and correlate its own logs (mirrors the
# Django backend's REQUEST_ID_RESPONSE_HEADER).
request_id = getattr(g, "request_id", None)
if request_id:
response.headers["X-Request-ID"] = request_id
return response
20 changes: 20 additions & 0 deletions workers/shared/clients/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@
APPLICATION_JSON = "application/json"


def _current_request_id() -> str | None:
"""Return the request_id bound on the current worker log context, if any.

Bound by the ``task_prerun`` handler in the logging module; used to
propagate ``X-Request-ID`` onto outbound calls to the backend internal API.
Returns ``None`` for the ``"-"`` placeholder so no empty header is sent.
"""
ctx = WorkerLogger.get_context()
request_id = getattr(ctx, "request_id", None) if ctx else None
if not request_id or request_id == "-":
return None
return request_id


# Single PG-queue rollout flag (same key as pg_queue.flags / executor_rpc).
_PG_QUEUE_FLAG_KEY = "pg_queue_enabled"

Expand Down Expand Up @@ -316,6 +330,12 @@ def _make_request(
if current_org_id:
headers["X-Organization-ID"] = current_org_id

# Propagate the correlation id back to the backend so worker
# callbacks share the originating request's request_id in logs.
request_id = _current_request_id()
if request_id:
headers["X-Request-ID"] = request_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The worker→backend hop does not actually correlate — the backend drops this header.

backend/backend/settings/base.py:279 sets LOG_REQUEST_ID_HEADER = "X-Request-ID", but django-log-request-id looks that value up in request.META, where WSGI maps HTTP headers to HTTP_X_REQUEST_ID:

# log_request_id/middleware.py::_get_request_id
return request.META.get(request_id_header, default_request_id)

The lookup therefore always misses, and because GENERATE_REQUEST_ID_IF_NOT_IN_HEADER = True the backend mints a fresh uuid4 for every request instead. Verified against the installed package:

LOG_REQUEST_ID_HEADER='X-Request-ID',      META={'HTTP_X_REQUEST_ID': 'abc-123-from-worker'} -> 98701742cad145069f9d1df02327af42
LOG_REQUEST_ID_HEADER='HTTP_X_REQUEST_ID', META={'HTTP_X_REQUEST_ID': 'abc-123-from-worker'} -> abc-123-from-worker

Concrete scenario: a file-processing worker calls the internal API with X-Request-ID: <originating id>; CustomRequestIDMiddleware ignores it, request.id becomes a new uuid4, CustomAuthMiddleware stores that in StateStore, and the backend log lines for the callback show an id unrelated to the worker's. So the "workers → backend" bullet in the PR description isn't achieved, and the same bug already silently discards the X-Request-ID the frontend forwards (frontend/src/helpers/requestId.js).

Fix is one line: LOG_REQUEST_ID_HEADER = "HTTP_X_REQUEST_ID" (keep REQUEST_ID_RESPONSE_HEADER = "X-Request-ID", which is a real header name and is correct as-is). Worth adding a test that posts X-Request-ID and asserts the response echoes the same value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713f9a4: LOG_REQUEST_ID_HEADER is now HTTP_X_REQUEST_ID. Spot on — and this also explains a red herring in our own dev smoke test, where the client X-Request-ID we sent was dropped and a fresh id minted (we had wrongly blamed the edge LB). The incoming header from the frontend and from workers is now actually honored. Thanks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The worker → runner hop stamps a different id, and it isn't in the out-of-scope list.

This closes the worker → backend hop, but the worker → runner hop already sends an X-Request-ID and it carries the wrong value:

# unstract/tool-sandbox/src/unstract/tool_sandbox/helper.py:439 (and :592)
headers = {
    "X-Request-ID": file_execution_id,
}

The runner reads that header via unstract.core.flask.register_request_id_middleware and renders it as request_id:<file_execution_id> — so after this PR the runner is the one service actively logging a different id under the same field name, which is exactly what breaks the single-request_id gcloud query the PR is built for.

This is called from worker context, where WorkerLogger.get_context().request_id is already bound by _bind_task_context, so the fix is the same shape as _current_request_id() here — prefer the propagated id and keep file_execution_id as the fallback.

Either close it or add it alongside the SDK1 x2text-adapter gap in the PR's out-of-scope list; right now it reads as covered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. tool-sandbox -> runner sends X-Request-ID = file_execution_id, which is inconsistent with standardizing on the HTTP request_id. Reconciling it means threading the request_id through tool-sandbox, which belongs with the tool-execution / OTel follow-up (same bucket as the SDK1 x2text-adapter forwarding). Added it explicitly to the PR out-of-scope list rather than leaving it silent.


if headers:
kwargs["headers"] = headers

Expand Down
83 changes: 76 additions & 7 deletions workers/shared/infrastructure/logging/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ class LogContext:
organization_id: str | None = None
correlation_id: str | None = None
request_id: str | None = None
# True only when request_id came from an upstream message header (a genuine
# cross-service correlation id), not a locally-derived payload id or the
# task_id fallback. Gates worker->worker re-propagation so a fallback id is
# never stamped onto child tasks (which would override their own
# file_execution_id correlation).
request_id_propagatable: bool = False


class RequestIDFilter(logging.Filter):
Expand Down Expand Up @@ -703,22 +709,82 @@ def _extract_request_id(
return None


def _request_id_from_message(task: Any) -> str | None:
"""Read an explicit request_id propagated via Celery message headers.

The task producer (backend ``before_task_publish`` handler, or a worker
re-publishing a downstream task) injects ``request_id`` into the message
headers. Celery exposes custom headers on ``task.request`` -- as a direct
attribute under protocol v2, and via the raw ``headers`` mapping as a
version-safe fallback. This is the authoritative cross-service correlation
id and takes precedence over payload-derived ids (file_execution_id, etc.).
"""
request = getattr(task, "request", None)
if request is None:
return None
value = getattr(request, "request_id", None)
if not value:
headers = getattr(request, "headers", None)
if isinstance(headers, Mapping):
value = headers.get("request_id")
return _coerce_id(value)


def _bind_task_context(task_id, task, args, kwargs, **_):
"""Celery ``task_prerun`` handler: bind request_id onto the log context.

Catches any extraction failure so a malformed payload can never leave
the previous task's id bound on the thread.
Resolution order: an explicit request_id propagated on the message headers
(genuine cross-service correlation), then a payload-derived id
(``_extract_request_id``), then the Celery ``task_id``. Only the first
(header) source is marked propagatable, so a locally-derived fallback is
never re-stamped onto child tasks.

The whole resolution runs inside the ``try`` so a malformed payload -- or a
surprising ``task.request`` -- can never raise and leave the previous task's
id bound on the thread.
"""
propagatable = False
try:
request_id = _extract_request_id(args or (), kwargs or {}, task) or task_id
request_id = _request_id_from_message(task)
if request_id:
propagatable = True
else:
request_id = _extract_request_id(args or (), kwargs or {}, task)
except Exception:
logging.getLogger(__name__).debug(
"request_id extraction failed for task %s; falling back to task_id",
task_id,
exc_info=True,
)
request_id = task_id
WorkerLogger.update_context(request_id=request_id, task_id=task_id)
request_id = None
request_id = request_id or task_id
WorkerLogger.update_context(
request_id=request_id,
task_id=task_id,
request_id_propagatable=propagatable,
)


def _propagate_request_id_on_publish(headers=None, **_):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PG-queue transport bypasses this hop entirely, so correlation breaks there.

workers/queue_backend/dispatch.py::enqueue routes to _enqueue_pg when resolve_backend(...) is QueueBackend.PG, which serialises via to_payload() and writes straight to pg_queue_message — it never calls current_app.send_task, so before_task_publish never fires and no request_id is carried. On the consume side consumer.py:575 runs task.apply(headers={FAIRNESS_HEADER_NAME: ...}), i.e. the only headers reaching Context are the fairness ones, so _request_id_from_message returns None and the task falls back to its payload id.

Concrete scenario: an API-triggered execution whose task name is in WORKER_PG_QUEUE_ENABLED_TASKS (or that rides a backend= pipeline override) loses the HTTP request_id at the first PG hop and never regains it for the rest of the execution — exactly the chains the PG rollout is moving onto. Worth threading request_id into to_payload() / restoring it into the headers passed to task.apply(), or at minimum calling this gap out in the PR's out-of-scope list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the PG transport enqueues via PgQueueMessage.objects.create(), so before_task_publish never fires and TaskPayload carries no request_id. Keeping this out of scope for this PR (Celery is the live default; pg_queue_enabled is fail-closed). The PGMQ owner has a precise carrier spec (add request_id to TaskPayload, populate in the backend and worker PG producers, inject into the consumer apply(headers=...)). Added to the PR out-of-scope section.

"""Celery ``before_task_publish`` handler (worker side): forward the current
request_id onto tasks this worker publishes.

Keeps a genuine cross-service correlation id flowing across worker->worker
task chains (e.g. a file-processing task enqueuing a callback). Only
propagates when the current id came from an upstream header
(``request_id_propagatable``) -- never a locally-derived payload id or the
``task_id`` fallback, which would otherwise override the child task's own
``file_execution_id`` correlation (e.g. for beat/scheduler-originated
pipelines). No-ops when absent or when the caller already set the header.
"""
if headers is None or headers.get("request_id"):
return
ctx = WorkerLogger.get_context()
if not ctx or not getattr(ctx, "request_id_propagatable", False):
return
request_id = _coerce_id(getattr(ctx, "request_id", None))
if request_id:
headers["request_id"] = request_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-publish handler combined with the new header-first precedence regresses correlation for beat-scheduled pipelines.

_bind_task_context falls back to request_id or task_id, so a task with no extractable payload id binds its own opaque Celery task_id. This handler then stamps that task_id onto every task it publishes, and the child's _request_id_from_message gives the header top priority over its own payload (above file_execution_id / execution_id).

Concrete scenario — scheduled ETL/TASK pipelines, the most common production flow:

  1. workers/scheduler/tasks.py:262 execute_pipeline_task_v2(organization_id, pipeline_id, pipeline_name) — none of _REQUEST_ID_KEYS (request_id/file_execution_id/execution_id/run_id) is present, so it binds its Celery task_id.
  2. It then dispatches async_execute_bin (workers/scheduler/tasks.py:172), whose payload does carry the real execution_id.
  3. Post-change that child — and every downstream file batch and callback — logs request_id:<scheduler-celery-task-id> instead of request_id:<execution_id>.

The PR's risk section says beat-scheduled tasks "fall back to the existing execution_id/task_id behaviour", but the header is now present on those chains, so they don't. Same shape applies to the per-file granularity _extract_request_id documents ("Workers bind file_execution_id … giving per-file granularity"): once any ancestor stamps a header, descendants can no longer surface their own id.

Suggest only propagating an id that is genuinely a cross-service correlation id — e.g. have _bind_task_context record whether the bound value came from a message header (or from a real request_id payload key) and have this handler forward it only in that case, leaving payload-derived / task_id fallbacks local to their own task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713f9a4. Added a request_id_propagatable marker to LogContext: _bind_task_context sets it True only when the id came from an upstream message header, and _propagate_request_id_on_publish now re-propagates only those — never a task_id or execution_id fallback. So beat/scheduler-originated child tasks keep deriving their own file_execution_id; only a genuine HTTP-origin id fans out across the chain.



def _clear_task_context(**_):
Expand All @@ -728,7 +794,9 @@ def _clear_task_context(**_):
``WorkerLogger.configure()``; only nulls out the per-task fields bound
in ``_bind_task_context``.
"""
WorkerLogger.update_context(request_id=None, task_id=None)
WorkerLogger.update_context(
request_id=None, task_id=None, request_id_propagatable=False
)


@functools.lru_cache(maxsize=1)
Expand All @@ -739,13 +807,14 @@ def _install_celery_request_id_signals() -> None:
debug log if Celery is not importable (e.g. unit tests).
"""
try:
from celery.signals import task_postrun, task_prerun
from celery.signals import before_task_publish, task_postrun, task_prerun
except ImportError as exc:
logging.getLogger(__name__).debug(
"celery.signals not importable; request_id signal install skipped: %s",
exc,
)
return
before_task_publish.connect(_propagate_request_id_on_publish, weak=False)
task_prerun.connect(_bind_task_context, weak=False)
task_postrun.connect(_clear_task_context, weak=False)

Expand Down
8 changes: 8 additions & 0 deletions x2text-service/app/config.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import logging
from os import environ as env

from dotenv import load_dotenv
from flask import Flask

from app.controllers import api
from app.logging_util import register_request_id_middleware, setup_logging
from app.models import X2TextAudit, be_db

load_dotenv()


def create_app() -> Flask:
log_level = getattr(logging, env.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
setup_logging(log_level)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old logging config it replaces is still in the tree.

# x2text-service/app/controllers/controller.py:19
logging.basicConfig(
    level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)

That runs at import of app.controllers (line 7 above), i.e. before create_app() is ever called. It happens to be harmless today only because dictConfig then clears and replaces the root handlers — but any path that imports the controller without going through create_app() (tests, an alternate entrypoint, a REPL) silently gets the pre-PR format with no request_id field at all.

Worth deleting as part of this change — it's the line setup_logging() is meant to supersede, and leaving both means the service has two competing logging configs whose behaviour depends on import order.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713f9a4 — removed the stale logging.basicConfig at controller.py import. setup_logging (dictConfig) is the single source now.


app = Flask(__name__)

# Assign/propagate a request_id (X-Request-ID) for cross-service log correlation.
register_request_id_middleware(app)

api_url_prefix = env.get("API_URL_PREFIX", "/api/v1")
app.register_blueprint(api, url_prefix=api_url_prefix)

Expand Down
6 changes: 2 additions & 4 deletions x2text-service/app/controllers/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@
from app.util import X2TextUtil

basic = Blueprint("basic", __name__)
# Configure the logging format and level
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
# Logging is configured centrally in app.logging_util.setup_logging() (called from
# create_app) with the request_id-aware canonical format shared across services.

UNSTRUCTURED_URL = "unstructured-url"
UNSTRUCTURED_API_KEY = "unstructured-api-key"
Expand Down
Loading
Loading