Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
71b71b8
feat: trace post processing and Valkey sessions (#345)
seonghobae Aug 21, 2026
707dcfc
fix: classify server failures in OTel telemetry
seonghobae Aug 21, 2026
005f066
fix: redact worker failure details
seonghobae Aug 21, 2026
c9e95e7
fix: flush telemetry and label TEPP transport
seonghobae Aug 21, 2026
83a0206
fix: harden OpenTelemetry failure boundaries
seonghobae Aug 21, 2026
58cfc4a
fix: ignore unsupported telemetry attribute keys
seonghobae Aug 21, 2026
b0c9bc6
test: enforce printable telemetry session ids
seonghobae Aug 21, 2026
7200049
fix(otel): retain endpoint operation diagnostics
seonghobae Aug 21, 2026
7a79cfa
fix(frontend): keep admin controls behind authentication
seonghobae Aug 21, 2026
e614946
fix(oidc): retain validated login return URLs
seonghobae Aug 21, 2026
b5f02dc
fix(otel): suppress automatic exception values in spans
seonghobae Aug 21, 2026
7451138
Merge branch 'feat/otel-session-diagnostics-main' of https://github.c…
seonghobae Aug 21, 2026
46e4d6d
fix: classify worker provider failures
seonghobae Aug 21, 2026
6525127
fix(otel): classify type errors as internal
seonghobae Aug 21, 2026
3584c31
fix(otel): suppress idle spans and mark HTTP errors
seonghobae Aug 21, 2026
6af3adc
fix(otel): pass exporter endpoint to orchestrator
seonghobae Aug 21, 2026
b1d32a9
fix(otel): trace Valkey read failures
seonghobae Aug 21, 2026
4eaa071
fix: retry transient broker outages
seonghobae Aug 21, 2026
2e2a8fe
fix(docs,tests): rename buyer-safe to reader-safe per ADR 0119
seonghobae Aug 23, 2026
81f728e
fix(otel): correlate Error spans with TraceId and SpanId
seonghobae Aug 23, 2026
67ec13e
fix(otel): sort imports and wrap ruff line-length in Ask HTTP paths
seonghobae Aug 23, 2026
602dab0
test(otel): require Valkey xadd to run under a child span
seonghobae Aug 23, 2026
0dbd0bb
fix(otel): label GET peers and keep session headers on orchestrator
seonghobae Aug 23, 2026
ab5d4c2
docs: bind OTel control evidence to exact upstream head
seonghobae Aug 23, 2026
731cb90
Merge origin/main into feat/otel-session-diagnostics-main
seonghobae Aug 24, 2026
874b36a
docs(changelog): promote CHANGELOG.d/2.13.2-otel-server-diagnostics.m…
seonghobae Aug 24, 2026
55ddb2f
Merge origin/main (leftover-map axis share) into feat/otel-session-di…
seonghobae Aug 24, 2026
b2b539b
Merge main + add reader-safe OTel server diagnostics
seonghobae Aug 24, 2026
823d106
Merge origin/main into feat/otel-session-diagnostics-main (take main'…
seonghobae Aug 24, 2026
352a889
Merge remote-tracking branch 'origin/main' into codex/pr383-repair
seonghobae Aug 24, 2026
e6d74ef
fix(otel): instrument queued Ask failures
seonghobae Aug 25, 2026
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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ contextual-orchestrator owns model discovery and selection.
caption and OCR (ADR 0155); never show an internal LLM instruction such as
`This post is an image` to a buyer.

## Observability boundary

- Follow governance-risk-compliance ADR 0009 and LineageWeave ADR 0122 for
OpenTelemetry. Use `OTEL_SERVICE_NAME` and
`OTEL_EXPORTER_OTLP_ENDPOINT`; exporting is opt-in and provider-neutral.
- Correlate one post's HTTP, contextual-orchestrator, and Valkey work with the
existing post-scoped session metadata. Do not create an ad hoc session table.
- Telemetry may contain bounded operation, route-template, service-peer, and
correlation attributes, but never post body, prompt, answer, source content,
actor or tenant identifiers, credentials, raw stream keys, or provider
responses. GRC remains the control/evidence owner.

## Source parsing and semantic units

- Preserve the source representation and provenance, then derive semantic
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.d/2.13.2-otel-server-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## 2.13.2

- Add reader-safe Global Ask and post-chat failures with 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.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ All notable changes to this project are documented here. Format follows
omits the badge rather than inventing a leftover score. Two-axis
reconstruction `R̂` stays internal and is not persisted.

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

Comment on lines +113 to +121

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.

## [2.12.19] - 2026-08-24

### Added
Expand Down
46 changes: 32 additions & 14 deletions backend/app/activity_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import redis.asyncio as redis
from fastapi import Request

from lineageweave.observability import traced


def create_valkey_client(url: str) -> redis.Redis:
"""One shared async client for the process, mirroring db.create_pool."""
Expand Down Expand Up @@ -66,12 +68,16 @@ async def publish_activity_event(
``approximate=True``) so one very active post's stream can't grow
without bound -- the panel only ever shows the most recent 50 anyway.
"""
return await client.xadd(
_stream_key(post_id),
_activity_fields(event_type, actor_account_id, summary),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.activity_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"},
):
return await client.xadd(
_stream_key(post_id),
_activity_fields(event_type, actor_account_id, summary),
maxlen=1000,
approximate=True,
)


def publish_activity_event_sync(
Expand All @@ -83,20 +89,32 @@ def publish_activity_event_sync(
) -> str | None:
"""Sync ``XADD`` for ``make seed``. Returns None if ``summary`` is already on the stream."""
key = _stream_key(post_id)
existing = client.xrevrange(key, count=50)
with traced(
"lineageweave.valkey.activity_xrevrange",
{"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"},
):
existing = client.xrevrange(key, count=50)
if any(fields.get("summary") == summary for _entry_id, fields in existing):
return None
return client.xadd(
key,
_activity_fields(event_type, str(actor_account_id), summary),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.activity_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "activity"},
):
return client.xadd(
key,
_activity_fields(event_type, str(actor_account_id), summary),
maxlen=1000,
approximate=True,
)


async def read_activity_events(client: redis.Redis, post_id: str, count: int = 50) -> list[dict[str, Any]]:
"""The post's most recent events, newest first."""
entries = await client.xrevrange(_stream_key(post_id), count=count)
with traced(
"lineageweave.valkey.activity_xrevrange",
{"db.system": "redis", "db.operation.name": "xrevrange", "lineageweave.stream.kind": "activity"},
):
entries = await client.xrevrange(_stream_key(post_id), count=count)
return [
{
"event_id": entry_id,
Expand Down
26 changes: 16 additions & 10 deletions backend/app/analysis_run_outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import redis.asyncio as redis

from lineageweave.observability import traced

OUTBOX_STREAM_KEY = "analysis-run-outbox"
_CLAIMED = "analysis_outbox_claimed"
_DELIVERED = "analysis_outbox_delivered"
Expand Down Expand Up @@ -69,16 +71,20 @@ async def publish_outbox_event(
if client is None:
return None
try:
entry_id = await client.xadd(
OUTBOX_STREAM_KEY,
outbox_stream_fields(
analysis_run_id=analysis_run_id,
work_kind_code=work_kind_code,
request_sha256=request_sha256,
),
maxlen=1000,
approximate=True,
)
with traced(
"lineageweave.valkey.analysis_outbox_xadd",
{"db.system": "redis", "db.operation.name": "xadd", "lineageweave.stream.kind": "analysis_outbox"},
):
entry_id = await client.xadd(
OUTBOX_STREAM_KEY,
outbox_stream_fields(
analysis_run_id=analysis_run_id,
work_kind_code=work_kind_code,
request_sha256=request_sha256,
),
maxlen=1000,
approximate=True,
)
except redis.RedisError:
return None
return str(entry_id)
Expand Down
8 changes: 7 additions & 1 deletion backend/app/analysis_run_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]:
"""POST the TEPP wire payload to `url`, raising TeppNotAvailable on any transport failure."""
try:
headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {}
return post_json(url, payload, headers=headers, timeout=30.0)
return post_json(
url,
payload,
headers=headers,
timeout=30.0,
service_peer_name="tepp",
)
except (HttpClientError, OSError, ValueError, TypeError) as exc:
# Chain internally for operator logging; the exposed
# message stays generic, never the raw provider exception text.
Expand Down
107 changes: 71 additions & 36 deletions backend/app/analysis_run_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@

from __future__ import annotations

import asyncio
import logging

import asyncpg
import redis.asyncio as redis
from uuid import UUID

from lineageweave.adjudication_client import AdjudicationClient
from lineageweave.observability import traced
from lineageweave.tepp_client import TeppClient

from backend.app.analysis_run_outbox import OUTBOX_STREAM_KEY
from backend.app.analysis_run_start import deliver_queued_analysis_run

_BROKER_RECOVERY_DELAY_SECONDS = 1.0
_worker_logger = logging.getLogger(__name__)


async def consume_analysis_run_stream_once(
client: redis.Redis,
Expand All @@ -31,36 +38,58 @@ async def consume_analysis_run_stream_once(
Invalid or stale entries are acknowledged by advancing the cursor; the
durable PostgreSQL outbox remains available for a later explicit retry.
"""
batches = await client.xread({OUTBOX_STREAM_KEY: last_id}, count=10, block=1000)
for _stream_name, entries in batches:
for entry_id, fields in entries:
analysis_run_id = str(fields.get("analysis_run_id", "")).strip()
try:
UUID(analysis_run_id)
except ValueError:
analysis_run_id = ""
if analysis_run_id:
async with pool.acquire() as conn:
async with conn.transaction():
owner = await conn.fetchrow(
"""
select requested_by_account_id
from analysis_run
where analysis_run_id = $1::uuid
""",
analysis_run_id,
)
if owner is not None:
await deliver_queued_analysis_run(
conn,
analysis_run_id=analysis_run_id,
account_id=str(owner["requested_by_account_id"]),
affiliated_entity_ids=[],
tepp_client=tepp_client,
adjudication_client=adjudication_client,
valkey_stream_entry_id=str(entry_id),
try:
batches = await client.xread({OUTBOX_STREAM_KEY: last_id}, count=10, block=1000)
except Exception:
# Keep idle polls silent, but retain a diagnostic span for broker failures.
with traced(
"lineageweave.valkey.analysis_outbox_xread",
{
"db.system": "redis",
"db.operation.name": "xread",
"lineageweave.stream.kind": "analysis_outbox",
},
):
raise
if not batches:
return last_id
with traced(
"lineageweave.valkey.analysis_outbox_batch",
{
"db.system": "redis",
"db.operation.name": "xread",
"lineageweave.stream.kind": "analysis_outbox",
},
):
for _stream_name, entries in batches:
for entry_id, fields in entries:
analysis_run_id = str(fields.get("analysis_run_id", "")).strip()
try:
UUID(analysis_run_id)
except ValueError:
analysis_run_id = ""
if analysis_run_id:
async with pool.acquire() as conn:
async with conn.transaction():
owner = await conn.fetchrow(
"""
select requested_by_account_id
from analysis_run
where analysis_run_id = $1::uuid
""",
analysis_run_id,
)
last_id = str(entry_id)
if owner is not None:
await deliver_queued_analysis_run(
conn,
analysis_run_id=analysis_run_id,
account_id=str(owner["requested_by_account_id"]),
affiliated_entity_ids=[],
tepp_client=tepp_client,
adjudication_client=adjudication_client,
valkey_stream_entry_id=str(entry_id),
)
last_id = str(entry_id)
return last_id


Expand All @@ -74,10 +103,16 @@ async def run_analysis_run_worker(
"""Run the single-process wake-up consumer until task cancellation."""
last_id = "0-0"
while True:
last_id = await consume_analysis_run_stream_once(
client,
pool,
last_id=last_id,
tepp_client=tepp_client,
adjudication_client=adjudication_client,
)
try:
last_id = await consume_analysis_run_stream_once(
client,
pool,
last_id=last_id,
tepp_client=tepp_client,
adjudication_client=adjudication_client,
)
except (redis.RedisError, OSError) as exc:
_worker_logger.warning(
"analysis-run Valkey poll failed; retrying (error_type=%s)", type(exc).__name__
)
await asyncio.sleep(_BROKER_RECOVERY_DELAY_SECONDS)
8 changes: 6 additions & 2 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@ def _jwks(settings: Settings, *, force_refresh: bool = False) -> dict:
if settings.oidc_jwks_uri_override:
jwks_uri = settings.oidc_jwks_uri_override
else:
metadata = get_json(settings.oidc_discovery_uri, timeout=10)
metadata = get_json(
settings.oidc_discovery_uri,
timeout=10,
service_peer_name="oidc",
)
jwks_uri = metadata.get("jwks_uri")
if not isinstance(jwks_uri, str) or not jwks_uri.strip():
raise ValueError("OIDC discovery document has no jwks_uri")
cached = get_json(jwks_uri, timeout=10)
cached = get_json(jwks_uri, timeout=10, service_peer_name="oidc")
except (HttpClientError, OSError, ValueError) as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
Expand Down
Loading
Loading