Skip to content

fix(otel): preserve reader-safe queued diagnostics - #593

Closed
seonghobae wants to merge 31 commits into
mainfrom
fix/otel-queued-diagnostics-v2132
Closed

fix(otel): preserve reader-safe queued diagnostics#593
seonghobae wants to merge 31 commits into
mainfrom
fix/otel-queued-diagnostics-v2132

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • restore the reader-safe OTel diagnostics closed with the empty fix: add reader-safe OTel server diagnostics #383 branch
  • instrument the asynchronous Global Ask worker after the endpoint-to-queue migration
  • classify source assembly, provider, contract, and internal failures without exception values
  • stop duplicating raw post-content exceptions in generic application logs

Closes #361.

Verification

  • focused observability/worker suite: 36 passed
  • Ruff on touched files: passed (existing SIM117 exclusions only)
  • full non-live progression: 1,006 passed, 17 skipped before the long-lived demo JWT expired during the 32-minute live PostgreSQL tail; the resulting 401 failures are fixture lifetime, not this diff
  • git diff --check: passed

Open in Devin Review

seonghobae and others added 30 commits August 21, 2026 22:08
* feat: trace post processing and valkey sessions

* docs: align orchestrator runtime pin

* docs: codify telemetry boundary

* fix: normalize OTLP trace endpoint

* chore: pin orchestrator telemetry fix

* fix: keep provider details out of ingestion ledger

* fix: propagate W3C trace context to gateway
ADR 0119 retired "buyer" project-wide the day before this PR's ADR
0122 and tests/test_server_diagnostics.py introduced it again in a
non-exempt new ADR and new source identifiers. Rename to
"reader-safe" to keep `grep -ri buyer` clean outside the ADR
0119-exempted historical ADRs, CHANGELOG*, and fixture/test domain
content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5M79L945DMyMs3sg5yJ14
Record classified Ask and post-chat failures inside the API span so
structured logs share the same W3C TraceId and SpanId. Inject
traceparent on GET as well as POST, export OTLP logs when an endpoint
is set, and keep a blank Compose interpolation from wiping orchestrator
env_file telemetry.
Keep backend.app.main and the HTTP client import-sorted and wrap the
over-length lines in the classified Ask/chat and GET tracing paths so
the diagnostics change is ruff-clean on the files it touches.
Assert the activity XADD span id differs from the parent so removing
the traced() wrapper cannot pass by inheriting the parent span.
OIDC, Searxng, and CalDAV GETs now set a bounded service.peer.name
instead of inheriting contextual-orchestrator. The post session header
stays on orchestrator and TEPP only; W3C traceparent still propagates.
Semantic graft: this PR's diagnostics (traced span, classified
record_server_failure, generic customer-facing 503s) onto main's
evolved surfaces -- the Global Ask route keeps main's newer response
shape (lineage_graph / cited_post_images and the 422 content
constant), the worker keeps main's docstring plus this PR's spans,
http_client keeps main's transport-error wrap alongside this PR's
observability plumbing, and the gap baseline unions both audit
sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J
…d, bump version

A peer session independently merged origin/main into this branch while
I was mid-merge on the same PR (functionally identical resolution to
mine, already pushed) -- rebased onto their tip rather than force-push
a redundant duplicate.

One genuine gap remained in both resolutions: CHANGELOG.d/2.13.2-otel-
server-diagnostics.md existed in this PR from the start but was never
promoted into CHANGELOG.md, and pyproject.toml/__init__.py/uv.lock
never bumped past main's 2.12.18 -- same version-bump gap already
fixed on #480/#482/#434/this-branch's-first-pass this session.

Verified: full suite 940 passed, 16 skipped, zero failures.
…agnostics-main

Resolves fresh conflicts in CHANGELOG.md (additive entry concatenation),
pyproject.toml (version bump), and uv.lock (relock with OTel deps).
Merge conflicts, largest in backend/app/main.py: this PR's import
section (predating a since-landed lineageweave.* client-import
reorganization) had to be reconstructed against main's current layout
rather than resolved line-by-line -- git's diff had fragmented the
same origin/main hunk across two non-adjacent conflict markers,
producing ~19 duplicate imports on a naive per-block resolution.
Rebuilt the whole import section from main's copy plus this PR's one
net-new import (lineageweave.observability), then re-verified there
were zero duplicate module imports before touching logic.

lifespan(): combined this PR's OTel-wrapped, defensive-shutdown
structure with main's independently-added third worker
(global_ask_worker) that didn't exist when this PR forked.

ask_agent(): main's evolution (durable job queue, GET /api/ask/jobs)
fully supersedes this PR's synchronous version; kept main's endpoints
as-is. The OTel span + classified-failure diagnostics this PR wanted
for Global Ask now belong on the actual provider-call site instead --
relocated into global_ask_queue.py's compute_global_ask_answer
(traced() span + record_server_failure(), alongside the already-merged
operability.py structured logging from #577, which serves a different
observability channel and isn't redundant with it). Moved source
gathering inside the traced/try block too so a source-assembly failure
shares the Ask span instead of escaping unclassified, matching this
PR's original intent for that failure mode.

Rewrote tests/test_server_diagnostics.py's 4 Global Ask tests to call
compute_global_ask_answer directly (the sync main.ask_agent they
called no longer exists) and to match the classification scheme
compute_global_ask_answer already had in production before this merge
(HttpClientError/OSError -> provider_unavailable, KeyError/ValueError
-> internal_error) rather than this PR's original one-tuple scheme,
since that split is the tested, already-shipped contract elsewhere.

post_content_worker.py: kept only the OTel record_server_failure
classification for the worker's terminal exception handler, not a
_logger.exception logging call for it too -- an already-existing,
unconflicted test in this same file asserts the raw exception message
must never appear in caplog even in server-side logs, which a bare
_logger.exception call would have violated.
…s version)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J
# Conflicts:
#	docs/product-technical-gap-baseline.md
@seonghobae
seonghobae enabled auto-merge (squash) August 25, 2026 00:24
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 10 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 667a4912-4147-4784-ad83-a4007ba36773

📥 Commits

Reviewing files that changed from the base of the PR and between 98e37fe and e6d74ef.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (32)
  • AGENTS.md
  • CHANGELOG.d/2.13.2-otel-server-diagnostics.md
  • CHANGELOG.md
  • backend/app/activity_stream.py
  • backend/app/analysis_run_outbox.py
  • backend/app/analysis_run_start.py
  • backend/app/analysis_run_worker.py
  • backend/app/auth.py
  • backend/app/global_ask_queue.py
  • backend/app/main.py
  • backend/app/post_content_queue.py
  • backend/app/post_content_worker.py
  • docker-compose.yml
  • docker/contextual-orchestrator/Dockerfile
  • docs/adr/0083-orchestrator-runtime-commit-pin.md
  • docs/adr/0122-otel-session-observability.md
  • docs/doctoring/OPENTELEMETRY_REFERENCES.md
  • lineageweave/caldav_client.py
  • lineageweave/http_client.py
  • lineageweave/observability.py
  • lineageweave/relation_verification.py
  • pyproject.toml
  • scripts/seed_demo_data.py
  • scripts/smoke_test_oidc.py
  • tests/test_activity_stream.py
  • tests/test_analysis_run_worker.py
  • tests/test_caldav_client.py
  • tests/test_http_client.py
  • tests/test_observability.py
  • tests/test_post_content_worker.py
  • tests/test_server_diagnostics.py
  • tests/test_tepp_client.py

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.

except RuntimeError as exc:
assert str(exc) == "provider failure"
else: # pragma: no cover
raise AssertionError("traced must preserve operation failures")
)
trace.set_tracer_provider(provider)
_TRACE_PROVIDER = provider
_CONFIGURED = True
global _LOG_HANDLER, _FAILURE_COUNTER
if _LOG_HANDLER is not None:
_LOGGER.removeHandler(_LOG_HANDLER)
_LOG_HANDLER = None
"telemetry.provider_shutdown_failed provider=%s",
provider_name,
)
_TRACE_PROVIDER = None
provider_name,
)
_TRACE_PROVIDER = None
_METER_PROVIDER = None
)
_TRACE_PROVIDER = None
_METER_PROVIDER = None
_LOG_PROVIDER = None

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

Open in Devin Review

Comment on lines 376 to +391
await republish_queued_post_content_jobs(client, pool)
last_recovery = now
last_id = await consume_post_content_stream_once(
client,
pool,
last_id=last_id,
vision_factory=vision_factory,
embedding_factory=embedding_factory,
structure_factory=structure_factory,
)
try:
last_id = await consume_post_content_stream_once(
client,
pool,
last_id=last_id,
vision_factory=vision_factory,
embedding_factory=embedding_factory,
structure_factory=structure_factory,
)
except (redis.RedisError, OSError) as exc:
_logger.warning(
"post-content Valkey poll failed; retrying (error_type=%s)", type(exc).__name__
)
await asyncio.sleep(_BROKER_RECOVERY_DELAY_SECONDS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Broker retry omits the recovery sweep

The redis/OS retry wraps only consume_post_content_stream_once; the periodic republish_queued_post_content_jobs call above it stays unguarded. This does not crash on a Valkey outage because the sweep's only broker call routes through publish_post_content_event, which swallows redis.RedisError at post_content_queue.py. A Postgres error during the sweep still kills the worker, matching consume's own unguarded DB path.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +164 to +175
session_id = current_session_id()
if session_id:
request_headers["x-lineageweave-session-id"] = session_id
with traced(
"lineageweave.http.post_json",
{
"http.request.method": "POST",
"lineageweave.operation_code": "http_post_json",
"service.peer.name": service_peer_name,
},
) as span:
inject_trace_context(request_headers)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Trace/session context survives into to_thread

client.answer runs under asyncio.to_thread, which copies contextvars into the worker thread. The active OTel span and the post-session metadata are therefore visible when post_json builds its child span and reads current_session_id(), so provider spans correctly parent to the API span and carry the session header. This is what the traceparent tests exercise.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +225 to +264
def _traced_get_json(
url: str,
*,
headers: dict[str, str] | None,
timeout: float,
decoder: Callable[[bytes, str], dict | list],
span_name: str,
service_peer_name: str,
):
"""GET ``url`` under one HTTP span and inject the active W3C context."""
hostname = urlparse(url).hostname or url
request_headers = dict(headers or {})
if service_peer_name in _SESSION_HEADER_PEERS:
session_id = current_session_id()
if session_id:
request_headers["x-lineageweave-session-id"] = session_id
with traced(
span_name,
{
"http.request.method": "GET",
"lineageweave.operation_code": "http_get_json",
"service.peer.name": service_peer_name,
},
) as span:
inject_trace_context(request_headers)
status, raw = _request(
"GET", url, body=None, headers=request_headers, timeout=timeout
)
if span is not None:
span.set_attribute("http.response.status_code", status)
if status >= 400:
if span is not None:
span.set_attribute("error.type", str(status))
raise HttpClientError(f"HTTP {status} from {hostname}")
try:
return decoder(raw, hostname)
except HttpClientError:
if span is not None:
span.set_attribute("error.type", "HttpClientError")
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Session header scoped away from third-party GETs

_traced_get_json adds x-lineageweave-session-id only for peers in _SESSION_HEADER_PEERS, so Searxng/CalDAV/OIDC GETs get traceparent but not the post session id. Note post_json with service_peer_name="tepp" does send the session header, whereas ADR 0122 names only contextual-orchestrator POSTs as carriers; confirm that reaching TEPP is intended.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread CHANGELOG.md
Comment on lines +113 to +121
## [2.13.2] - 2026-08-24

### Added

- Reader-safe Global Ask and post-chat failures now carry bounded
OpenTelemetry metrics, traces, and structured server diagnostics for
GRC consumption. Failure logs carry the active TraceId and SpanId so
another agent can join the Error span to the audit record.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: New changelog entry sits out of version order

The added ## [2.13.2] block lands between ## [2.12.26] and ## [2.12.19], while pyproject.toml remains 2.15.0. The section is only loosely date-ordered, so this is cosmetic, but the placement reads oddly against the surrounding version numbers.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Superseded by exact #383 head 17341fe, which now contains this PR’s queued Global Ask instrumentation and reader-safe post-content logging on top of the original OTel delivery. Focused validation: 36 passed. Closing the duplicate delivery path; no work is discarded.

@seonghobae seonghobae closed this Aug 25, 2026
auto-merge was automatically disabled August 25, 2026 00:34

Pull request was closed

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.

[Operability] Preserve server diagnostics behind generic orchestrator 503 responses

1 participant