Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
7ae8eb9
merge: take main's refreshed gap baseline over the stale branch copy
seonghobae Aug 24, 2026
17341fe
fix(otel): instrument queued Ask failures
seonghobae Aug 25, 2026
bb96619
merge: rebase onto main; union both import blocks in the worker test
seonghobae Aug 25, 2026
d1a4ff2
merge: rebase onto main; union OTel spans with per-run refusal handling
seonghobae Aug 25, 2026
138eaad
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 25, 2026
1947595
fix(api): keep Ask configuration details reader-safe
seonghobae Aug 25, 2026
6da581b
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 25, 2026
b71ca5a
docs(changelog): restore descending release order
seonghobae Aug 25, 2026
7ff6b94
fix(telemetry): bound session metadata to service peers
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 @@ -145,6 +145,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 @@ -147,6 +147,15 @@ All notable changes to this project are documented here. Format follows
is unwired, with `이 범위의 일정을 아직 받을 수 없습니다`. Weekly VOC and
newspaper stay on the board.

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

## [2.12.26] - 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 @@ -137,7 +137,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
131 changes: 81 additions & 50 deletions backend/app/analysis_run_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +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_ingestion import AnalysisRunCreateError
from backend.app.analysis_run_outbox import OUTBOX_STREAM_KEY
from backend.app.analysis_run_start import deliver_queued_analysis_run

logger = logging.getLogger(__name__)
_BROKER_RECOVERY_DELAY_SECONDS = 1.0
_worker_logger = logging.getLogger(__name__)


async def consume_analysis_run_stream_once(
Expand All @@ -36,50 +39,72 @@ 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:
# One run's fail-closed refusal (404/409/503, e.g. channel
# weights not estimated yet, ADR 0145) must not end the
# worker task and halt every later run's delivery. The
# transaction rolls back, the durable outbox row stays
# available, and an explicit HTTP start retries the run
# once the operator resolves the named next action.
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:
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),
UUID(analysis_run_id)
except ValueError:
analysis_run_id = ""
if analysis_run_id:
# One run's fail-closed refusal (404/409/503, e.g. channel
# weights not estimated yet, ADR 0145) must not end the
# worker task and halt every later run's delivery. The
# transaction rolls back, the durable outbox row stays
# available, and an explicit HTTP start retries the run
# once the operator resolves the named next action.
try:
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,
)
except AnalysisRunCreateError as exc:
logger.warning(
"analysis-run %s delivery refused (%s): %s",
analysis_run_id,
exc.status_code,
exc.detail,
)
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),
)
except AnalysisRunCreateError as exc:
_worker_logger.warning(
"analysis-run %s delivery refused (%s): %s",
analysis_run_id,
exc.status_code,
exc.detail,
)
last_id = str(entry_id)
Comment thread
seonghobae marked this conversation as resolved.
return last_id


Expand All @@ -93,10 +118,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