Skip to content

UN-2123 [FEAT] Propagate request_id across services and workers - #2229

Open
Deepak-Kesavan wants to merge 3 commits into
mainfrom
UN-2123-propagate-request-id
Open

UN-2123 [FEAT] Propagate request_id across services and workers#2229
Deepak-Kesavan wants to merge 3 commits into
mainfrom
UN-2123-propagate-request-id

Conversation

@Deepak-Kesavan

@Deepak-Kesavan Deepak-Kesavan commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

Completes end-to-end request_id (X-Request-ID) correlation so a single ID
can filter logs across the backend, Celery workers and services
in gcloud.

  • backend → workers: a before_task_publish signal
    (backend/backend/celery_signals.py) injects the request-scoped request_id
    (from StateStore) into every published Celery task's message headers — no
    per-send_task changes required.
  • workers: task_prerun now prefers an explicit request_id from the
    message headers over payload-derived ids (file_execution_id, etc.), and a
    new before_task_publish handler re-propagates it onto downstream
    worker→worker task chains.
  • workers → backend: the internal-API HTTP client now forwards
    X-Request-ID from the worker log context, so backend callbacks share the
    originating request's id.
  • x2text-service: reads/mints X-Request-ID and logs it via a
    self-contained logging module (no new dependency).
  • logging: all services unified onto one canonical log format so
    request_id / trace_id / span_id parse identically in gcloud.

Why

Debugging across services is painful when logs can't be correlated. The backend
already assigned a request_id per request (and the frontend forwards one), but
it died at the backend boundary — it was never propagated to the Celery workers
or echoed on worker→backend calls, so worker logs showed request_id:- (or an
execution_id that didn't match the originating request). This threads the same
id through the whole chain.

Jira: UN-2123

How

The worker-side receiving/logging machinery already existed (UN-3435) and was
explicitly designed to accept a real request_id from the producer — this PR
supplies the missing producer side and closes the remaining hops:

  1. before_task_publish (backend) reads StateStore.get(Common.REQUEST_ID) and
    sets headers["request_id"]. Guarded so it can never break task publishing.
  2. Worker _bind_task_context (task_prerun) reads task.request.request_id
    (Celery 5.6.2 exposes custom message headers on the task Context; a raw
    request.headers mapping is used as a version-safe fallback). Resolution
    order: message-header request_id → payload-derived id → Celery task_id.
  3. Worker _propagate_request_id_on_publish (before_task_publish) forwards the
    bound request_id onto tasks the worker itself publishes.
  4. base_client._make_request adds X-Request-ID from the worker log context.
  5. x2text-service create_app() wires setup_logging +
    register_request_id_middleware from a new self-contained app/logging_util.py.
  6. The shared Flask formatter (unstract/core/flask/logging.py) is aligned to
    the canonical backend/worker format.

Out of scope (recommended as separate tickets):

  • LLM Whisperer (LLMW) adoption of this standard — separate repo/product.
  • Full OpenTelemetry trace activation. The OTel scaffolding (trace_id
    fields in every formatter, meta-deps) is currently inert — no service runs
    under opentelemetry-instrument, exporters are hardcoded to none, and there
    is no OTLP endpoint — so trace_id/span_id always render as -. Activating
    real distributed tracing is a larger DevOps/observability effort. This PR uses
    request_id (which the frontend/backend already surface via the
    X-Request-ID response header) as the pragmatic correlation key.
  • SDK1 x2text-adapter → x2text-service header forwarding — needs the SDK
    execution request_id threaded through several tool-side layers; belongs with
    the tool-execution/OTel correlation follow-up. (x2text still logs any
    X-Request-ID it receives after this PR.)
  • tool-sandbox → runner currently sends X-Request-ID = file_execution_id,
    inconsistent with the HTTP request_id standardized here; reconciling it means
    threading the request_id through tool-sandbox — same tool-execution/OTel bucket.
  • PGMQ transport (pg_queue_enabled) — a separate, non-Celery transport
    (DB-INSERT enqueue + custom poll-loop consumer) that this PR's Celery-signal
    propagation does not cover. Celery is the live default; a matching carrier
    (request_id on TaskPayload, populated in the PG producers, read into the
    consumer's apply(headers=…)) is handed to the PGMQ owner so correlation
    stays complete as pg_queue_enabled ramps.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

Low risk — the change is additive and defensively guarded:

  • Both before_task_publish handlers no-op safely (null-guarded, StateStore
    read wrapped in try/except) so they cannot break task publishing even if
    no request_id is in scope (e.g. beat-scheduled tasks fall back to the
    existing execution_id/task_id behaviour).
  • Reading task.request.request_id uses getattr(..., None) and only overrides
    the previously-working payload-derived id when a header is actually present —
    so existing worker correlation is preserved when the header is absent.
  • One intentional log-format change: the shared Flask formatter now matches
    the canonical backend/worker format, which means Flask-service log lines
    (platform-service, runner) show module:<source-module> instead of the
    logger name. Log content is otherwise unchanged; any dashboard keying on
    the logger name string would need updating (grep-by-request_id/trace_id is
    unaffected and is the point of the change).
  • No API contract, DB, or schema changes.

Database Migrations

None.

Env Config

None. (x2text-service reads the existing LOG_LEVEL env if set; defaults to INFO.)

Relevant Docs

Related Issues or PRs

Dependencies Versions

None added. (x2text-service deliberately avoids taking on unstract-core; it
uses a small self-contained logging module instead.)

Notes on Testing

  • Static: all touched files py_compile-clean; pre-commit (ruff, ruff-format,
    pycln, pyupgrade, secret-scan, test-selection) green.
  • Verified against Celery 5.6.2 that a custom key in the message headers is
    exposed on the task Context via both attribute and .get() access.
  • One runtime smoke test still recommended on a live cluster (not yet run):
    trigger a workflow/API execution and confirm a single X-Request-ID from the
    originating HTTP request appears in the worker log lines (request_id:<id>)
    and on the worker→backend internal-API calls. If Celery header→Context
    attribute promotion ever regressed, the request.headers fallback covers it,
    but the smoke test is the real guarantee.

Screenshots

N/A (no UI surface).

Checklist

I have read and understood the Contribution Guidelines.

Complete end-to-end request_id (X-Request-ID) correlation so a single ID can
be used to filter logs across the backend, Celery workers and services in
gcloud.

- backend: before_task_publish signal injects the request-scoped request_id
  (from StateStore) into every published Celery task's message headers, with
  no per-call-site changes.
- workers: task_prerun now prefers an explicit request_id from the message
  headers over payload-derived ids; a before_task_publish handler re-propagates
  it onto downstream worker->worker task chains.
- workers: internal-API HTTP client now forwards X-Request-ID from the worker
  log context so backend callbacks share the originating request's id.
- x2text-service: reads/mints X-Request-ID and logs it (self-contained, no new
  dependency).
- logging: unified all services onto one canonical log format so request_id/
  trace_id/span_id parse identically in gcloud.

LLMW adoption and full OpenTelemetry trace activation are intentionally out of
scope (separate tickets).
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features
    • Added end-to-end request ID tracking across web requests, background tasks, and service-to-service calls.
    • Request IDs are now propagated automatically through asynchronous task processing and outbound requests.
    • Requests without an ID receive a generated identifier for reliable correlation.
  • Improvements
    • Standardized log fields and formatting, including request, trace, process, and thread information.
    • Improved task-level request ID prioritization and handling when identifiers are unavailable.

Walkthrough

The changes add request ID middleware and canonical logging fields, propagate request IDs through Celery task headers, bind them in workers, and forward them in outbound worker HTTP requests.

Changes

Request ID observability

Layer / File(s) Summary
Service request logging
x2text-service/app/config.py, x2text-service/app/logging_util.py
The service configures logging from LOG_LEVEL. Middleware records X-Request-ID or generates a UUID. Logging filters add request and OpenTelemetry fields.
Canonical logging fields
unstract/core/src/unstract/core/flask/logging.py
Flask logs now use canonical module, process, thread, request, trace, and span fields.
Celery request propagation
backend/backend/celery_signals.py, backend/backend/celery_service.py, workers/shared/infrastructure/logging/logger.py
Producer signals add request IDs to task headers. Worker context binding prioritizes header IDs, then payload IDs, then task IDs. Signal installation includes before_task_publish.
Worker HTTP propagation
workers/shared/clients/base_client.py
Workers include the bound request ID in the outbound X-Request-ID header when it is available.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FlaskApp
  participant StateStore
  participant Celery
  participant WorkerLogger
  participant BaseClient

  FlaskApp->>FlaskApp: capture or generate request_id
  FlaskApp->>StateStore: store request_id
  Celery->>StateStore: read request_id before publish
  StateStore-->>Celery: return request_id
  Celery->>Celery: add request_id to task headers
  Celery->>WorkerLogger: deliver task message
  WorkerLogger->>WorkerLogger: bind request_id from headers
  WorkerLogger->>BaseClient: provide current request_id
  BaseClient->>BaseClient: send X-Request-ID
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main request_id propagation change across services and workers.
Description check ✅ Passed The description follows the required template and provides clear scope, rationale, implementation details, risks, testing, and related information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch UN-2123-propagate-request-id

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@x2text-service/app/logging_util.py`:
- Around line 31-33: Update RequestIDFilter.filter to call has_request_context()
before accessing Flask’s context-local g; assign the request ID from g only when
a request context exists, otherwise retain "-".
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6da06d9c-e764-45ab-a92a-7479f521926e

📥 Commits

Reviewing files that changed from the base of the PR and between aac5ede and e217f4d.

📒 Files selected for processing (7)
  • backend/backend/celery_service.py
  • backend/backend/celery_signals.py
  • unstract/core/src/unstract/core/flask/logging.py
  • workers/shared/clients/base_client.py
  • workers/shared/infrastructure/logging/logger.py
  • x2text-service/app/config.py
  • x2text-service/app/logging_util.py

Comment on lines +31 to +33
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = getattr(g, "request_id", "-") if g else "-"
return True

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)logging_util\.py$|requirements|pyproject|setup|Pipfile|poetry|gunicorn|main|wsgi|app'

echo "== logging_util.py =="
if [ -f x2text-service/app/logging_util.py ]; then
  cat -n x2text-service/app/logging_util.py
fi

echo "== flask imports/usages of RequestIDFilter/Logging =="
rg -n "RequestIDFilter|LoggingMiddleware|logging_util|log_config|handlers|gunicorn|request_id|from flask import|import flask" -S .

Repository: Zipstack/unstract

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect Flask proxy g semantics from installed package if available =="
python3 - <<'PY'
try:
    from flask import g, has_request_context
    print("flask_available=True")
    print(has_request_context())
    try:
        _ = bool(g)
        print("bool_g_outside_request_context=True")
    except Exception as e:
        print(f"bool_g_outside_request_context_error={type(e).__name__}: {e}")
    try:
        _ = getattr(g, "request_id", "-") if g else "-"
        print("getattr_if_bool_g_outside_request_context=True")
    except Exception as e:
        print(f"getattr_if_bool_g_outside_request_context_error={type(e).__name__}: {e}")
except Exception as e:
    print(f"flask_available_or_probe_error={type(e).__name__}: {e}")
PY

Repository: Zipstack/unstract

Length of output: 305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== similar core logging filter =="
cat -n unstract/core/src/unstract/core/flask/logging.py

echo "== exact matching RequestIDFilter definitions using Flask g =="
python3 - <<'PY'
from pathlib import Path
import re

patterns = [
    re.compile(r"class RequestIDFilter\(logging\.Filter\):"),
    re.compile(r"getattr\(\bg\b, .request_id"),
    re.compile(r"\n\s+if \bg\b else \"\-\""),
]
for path in Path(".").rglob("*.py"):
    text = path.read_text(errors="ignore")
    if "RequestIDFilter" in text and "g, \"request_id\"" in text:
        print("\n---", path, "---")
        for i, m in enumerate(re.finditer(r"class RequestIDFilter\(logging\.Filter\):", text)):
            print(f"definition instance {i+1} at line 1..")
            line = text[:m.start()].count("\n") + 1
            print(line)
            start = m.start()
            end = text.find("\nclass ", start + 1)
            snippet = text[start:end if end != -1 else None]
            print(snippet[:2000])
PY

Repository: Zipstack/unstract

Length of output: 4964


🌐 Web query:

Flask g request_context proxy RuntimeError outside application context has_request_context documentation

💡 Result:

The RuntimeError: Working outside of application context or Working outside of request context in Flask occurs when you attempt to access proxy objects (like g, current_app, request, or session) without an active corresponding context [1][2][3]. The g object is a proxy to a namespace used to store data during a single request or application context [4]. It is bound to the application context, meaning it is only available when an application context is active [1][4]. To resolve these errors, you must ensure your code runs within the appropriate context: 1. Application Context: If you need to access current_app or g outside of a view function (e.g., in a CLI command, initialization script, or background task), wrap your code in a with app.app_context: block [1][2][5]. 2. Request Context: If you need to access request or session objects, you generally need an active HTTP request [3]. When testing, you can use with app.test_request_context: to simulate an environment [3][6]. 3. has_request_context: Flask provides a utility function, flask.has_request_context, which returns a boolean indicating whether a request context is currently active [4]. Similarly, flask.has_app_context can be used to check for an active application context [7]. These errors often arise when code is placed outside of a view function or is being executed in a context where Flask has not been initialized [1][2][3]. If you are attempting to interact with a Flask application from an external script, do not use Flask's request object; instead, use standard HTTP libraries like requests to send requests to the running application [8].

Citations:


Guard Flask context access in RequestIDFilter.

Line 32 dereferences Flask’s context-local g, which can raise RuntimeError when logging runs outside an application/request context; use has_request_context() before accessing it.

Proposed fix
-from flask import Flask, g, request
+from flask import Flask, g, has_request_context, request

 class RequestIDFilter(logging.Filter):
     def filter(self, record: logging.LogRecord) -> bool:
-        record.request_id = getattr(g, "request_id", "-") if g else "-"
+        record.request_id = (
+            getattr(g, "request_id", "-") if has_request_context() else "-"
+        )
         return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = getattr(g, "request_id", "-") if g else "-"
return True
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = (
getattr(g, "request_id", "-") if has_request_context() else "-"
)
return True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@x2text-service/app/logging_util.py` around lines 31 - 33, Update
RequestIDFilter.filter to call has_request_context() before accessing Flask’s
context-local g; assign the request ID from g only when a request context
exists, otherwise retain "-".

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 call — applied in 2fd885a. Switched to has_request_context() before reading g, so log records outside an active request context (e.g. gunicorn startup) deterministically get request_id:- rather than relying on LocalProxy.__bool__ semantics. Thanks!

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR propagates request IDs through backend Celery publishing, worker task chains, worker-to-backend callbacks, and x2text requests while standardizing service log fields.

  • Adds backend and worker Celery signal handlers for binding, forwarding, and clearing request IDs.
  • Forwards worker request IDs through internal HTTP calls and corrects Django’s incoming request-header setting.
  • Adds request-ID middleware and canonical logging to x2text and shared Flask services.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
backend/backend/celery_signals.py Adds producer and backend-worker signal handlers that propagate request IDs through Celery headers and task-local logging state.
workers/shared/infrastructure/logging/logger.py Prefers message-header request IDs, tracks whether they are safe to propagate, and forwards them through worker task chains.
workers/shared/clients/base_client.py Adds the active worker request ID to internal backend HTTP calls.
x2text-service/app/logging_util.py Introduces self-contained request-ID middleware and canonical structured logging for x2text.
unstract/core/src/unstract/core/flask/middleware.py Echoes the active request ID in Flask response headers.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Backend
    participant Broker
    participant WorkerA as Worker
    participant WorkerB as Downstream Worker
    participant X2Text
    Client->>Backend: HTTP request + X-Request-ID
    Backend->>Broker: Publish task + request_id header
    Broker->>WorkerA: Deliver task
    WorkerA->>WorkerA: Bind request_id to log context
    WorkerA->>Broker: Publish child + request_id header
    Broker->>WorkerB: Deliver child task
    WorkerA->>Backend: Internal callback + X-Request-ID
    Client->>X2Text: Request + optional X-Request-ID
    X2Text-->>Client: Response + X-Request-ID
Loading

Reviews (3): Last reviewed commit: "UN-2123 [FIX] Address review: honor inco..." | Re-trigger Greptile

@pk-zipstack pk-zipstack left a comment

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.

Code review — 4 findings on the request_id propagation chain. The producer/consumer plumbing itself looks correct (verified against Celery 5.5.3 that a custom key in the v2 message headers is promoted onto Task.request both as an attribute and via request.headers, and that mutating the headers dict inside before_task_publish reaches producer.publish). The issues are at the two ends of the chain and in the precedence rule.

# 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.

ctx = WorkerLogger.get_context()
request_id = _coerce_id(getattr(ctx, "request_id", None)) if ctx else 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.

WorkerLogger.update_context(request_id=request_id, task_id=task_id)


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.

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.

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

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.

A few points on hops and guarantees that I think are still open — details inline.

# 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 → 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.


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.before_request
def _assign_request_id() -> None:
g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))

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.

x2text mints a request_id that nothing can ever learn.

There's no after_request echoing X-Request-ID back on the response — the Django backend does this via REQUEST_ID_RESPONSE_HEADER, and the frontend relies on it. Combined with the SDK1 x2text-adapter not forwarding the header (acknowledged as out of scope), the practical outcome today is: x2text generates a fresh uuid4 per request, receives it from nobody, and returns it to nobody. The id appears only in x2text's own log lines, where it correlates with nothing.

A three-line after_request at least makes it retrievable from a live call now, ahead of the SDK-side follow-up:

    @app.after_request
    def _echo_request_id(response):
        response.headers["X-Request-ID"] = g.get("request_id", "-")
        return response

Same gap exists in the shared unstract/core/src/unstract/core/flask/middleware.py:15, so fixing it in both keeps the two copies aligned.

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 an after_request that echoes X-Request-ID, and put it in the shared unstract.core.flask middleware too, so platform-service and runner echo consistently, not just x2text.

exc_info=True,
)
request_id = task_id
request_id = _request_id_from_message(task)

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 call sits outside the try, which breaks the guarantee the docstring three lines above still promises.

Catches any extraction failure so a malformed payload can never leave the previous task's id bound on the thread.

Before this PR the whole resolution — including the or task_id fallback — was inside the try, so update_context always ran. Now _request_id_from_message(task) runs unguarded: if it raises, WorkerLogger.update_context(...) on line 749 is never reached, and the previous task's request_id and task_id stay bound on the thread-local context. That is precisely the failure mode the guard was written for, and it's worse than the old one because a stale id is silently plausible where a missing one is obvious.

A raising task_prerun receiver also fails the task itself (send_prerun is inside the traced task body in celery/app/trace.py), so the blast radius isn't only logging.

Moving the call inside the existing try keeps the invariant with no other change:

    try:
        request_id = _request_id_from_message(task) or _extract_request_id(
            args or (), kwargs or {}, task
        )
    except Exception:
        ...
        request_id = None

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 — moved the whole resolution (header read, payload extraction, and the or task_id fallback) inside the try, so update_context always runs and the docstring guarantee holds again.

…ion, backend reader

- settings: LOG_REQUEST_ID_HEADER must be the WSGI META key HTTP_X_REQUEST_ID,
  else django-log-request-id never reads the incoming X-Request-ID and mints a
  fresh id every request (broke frontend + worker->backend correlation).
- workers: run all of _bind_task_context's resolution inside the try (restores
  the 'never leave prior task's id bound' guarantee); mark only header-origin
  ids propagatable so _propagate_request_id_on_publish never stamps a local
  fallback (task_id/execution_id) onto child tasks and clobbers their own
  file_execution_id correlation (beat/scheduler pipelines).
- backend celery: add task_prerun/postrun reader so the backend's OWN Celery
  workers (beat, dashboard tasks) consume the injected header instead of
  logging request_id:- (the injection had no reader on that side).
- flask (core + x2text): echo X-Request-ID on responses so callers can learn a
  minted id (mirrors backend REQUEST_ID_RESPONSE_HEADER).
- x2text: drop the stale module-import logging.basicConfig superseded by
  setup_logging's dictConfig.
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 16.7
e2e-coowners e2e 1 0 0 0 1.4
e2e-etl e2e 1 0 0 0 8.3
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.5
e2e-smoke e2e 2 0 0 0 1.1
e2e-workflow e2e 1 0 0 0 16.7
integration-backend integration 267 0 0 26 46.0
integration-connectors integration 1 0 0 7 8.1
integration-workers integration 140 0 0 1 50.3
unit-backend unit 998 0 0 1 40.9
unit-connectors unit 63 0 0 0 10.1
unit-core unit 33 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 117 0 0 0 5.5
unit-sdk1 unit 480 0 0 0 26.3
unit-workers unit 1335 0 0 1 88.0
TOTAL 3460 0 0 36 329.2

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants