Skip to content

Commit 092361e

Browse files
krokokobgagent
andauthored
feat(observability): correlation envelope across orchestrator logs, TaskEvents, agent OTel (#245) (#557)
* feat(observability): correlation envelope across orchestrator logs, TaskEvents, agent OTel (#245) Make the {task_id, user_id, repo, trace_id} correlation envelope observable on every plane so a task's actions join across CloudWatch logs, TaskEvents, and the X-Ray trace without manual stitching. Builds on #515, which already persists otel_trace_id on the TaskRecord (the orchestrator<->agent bridge). Phase 0 — contract: - docs/design/OBSERVABILITY.md: "Correlation envelope" section — field table (repo/trace_id/session_id nullable), shared snake_case naming with #215/#237/ #249, and the join model (TaskRecord bridges the two planes; no single W3C root since the orchestrator runs before the agent trace exists). Starlight mirror synced. Phase 1 — orchestrator (TS): - logger.ts: add logger.child(context) merging a persistent envelope into every line; per-call data wins on collision. - orchestrate-task.ts + shared/orchestrator.ts: derive a child logger in the lifecycle functions (orchestrate handler, finalizeTask, hydrateAndTransition, failTask) so admission->terminal log lines carry {task_id, user_id, repo}. - emitTaskEvent gains an optional EventCorrelation param; lifecycle events now stamp user_id/repo as top-level fields (repo omitted for repo-less workflows). Phase 1 — agent (Python): - progress_writer.py: _ProgressWriter takes user_id/repo; _put_event stamps {user_id, repo, trace_id} (trace_id read per-event from the active span) so the agent event stream joins to X-Ray. Optional fields omitted, never null. - observability.py: set_session_id propagates user.id/repo.url baggage, guarding each field (empty/None not stamped). - pipeline.py: root task.pipeline span gains user.id; both writer sites pass the envelope. server.py: propagate even when session_id is empty. Phase 2 needs no new code: #515's TaskRecord.otel_trace_id plus the agent event trace_id stamping give the cross-plane join. Both _ProgressWriters are built inside an active span, so trace_id is always valid. Tests: logger child-merge (4), orchestrate-task envelope incl. repo-less (3), progress_writer envelope incl. omit-not-null (4), set_session_id incl. empty session (3). No #209 SessionRole regression (user_id is read-only here). CDK 2236 pass, agent 1132 pass; eslint/ruff/docs-sync clean. * fix(#245): address PR #557 review — trace-read fail-open, repo type, coverage Review (4 plugin agents) found no blocking issues; addressed the three substantive items: - silent-failure (MEDIUM): current_otel_trace_id() is read inside progress_writer._put_event's DDB try-block. If a misconfigured tracer raised, the error was caught by the DDB handler, misclassified as "unknown", and tripped the SHARED progress circuit breaker — silencing the whole event stream (turns, milestones, #251 agent_blocked) over a tracing fault. Make current_otel_trace_id() never raise: a tracer error degrades to None (its documented graceful-missing contract), so the trace_id field is simply omitted. Fixes every caller, incl. #515's crash-path persistence. - type coherence: narrow EventCorrelation.repo and failTask's repo param from `string | null` to `string | undefined`, matching the source TaskRecord.repo (never null). Removes the redundant third "absent" state; consumers already omit-on-falsy so behavior is unchanged. tsc + eslint clean. - test coverage: add the highest-value missing test — _run_task_background reaches set_session_id with the envelope on the empty-session/known-user branch (the widened trigger the feature depends on). Add a defensive test that a throwing tracer degrades current_otel_trace_id() to None. Skipped (churn > value, behavior already correct): failTask→EventCorrelation refactor, Python str=""-vs-None normalization, level/message collision guard. agent 1182 pass, CDK orchestrate-task+logger 107 pass; ruff/eslint/tsc clean. * style(#245): ruff-format new test files (fix CI mutation) CI 'Fail build on mutation' tripped: ruff format rewrote the backslash line-continuation `with` statements in the two new test files into the parenthesized multi-context-manager form. Apply the formatter locally and commit, matching the repo convention. No behavior change. * feat(#245): surface correlation envelope in event API + close review nits Independent review follow-ups (all non-blocking) folded into this PR: 1. API passthrough (#1): the {user_id, repo, trace_id} envelope was stamped on TaskEvents in DDB but stripped by get-task-events.ts and get-task-replay.ts, so bgagent watch/events/replay never surfaced per-event correlation. Add the three optional fields to EventRecord + ReplayEvent (cdk) and TaskEvent + ReplayEvent (cli mirror), and pass them through in both handlers (omit-when- absent). API_CONTRACT.md documents the per-event envelope; types-sync clean. 2. Coverage docs (#2): OBSERVABILITY.md now notes task_created predates the envelope and joins by task_id alone, and that CLI consumers can join at the task level via TaskRecord.otel_trace_id. 3. Doc wording (#3): correlation-envelope table says repo is "absent (key omitted, not null/'')" for repo-less workflows, matching the implementation (#248) instead of the earlier "null". 4. Consistency (#4): failTask's transition-failure log and loadBlueprintConfig now use logger.child({task_id,user_id?,repo?}) like the rest of the lifecycle code, instead of base logger + inlined fields. Tests: direct emitTaskEvent(..., correlation) unit tests (stamp / repo-less omit / back-compat no-envelope); get-task-events + get-task-replay passthrough incl. task_created omission. CDK 2256 pass + synth clean; CLI 578 pass; agent 1182 pass; eslint/ruff/tsc/ types-sync/docs-sync clean. * refactor(#245): address review — rename, envelopeFor helper, loadBlueprintConfig user_id Second independent review (ayushtr-aws, approve-with-minor): all 6 minor items. 1. Rename set_session_id → propagate_correlation_context (observability.py): it propagates the full envelope, not just session id, and is called with an empty session_id — the old name misled. Updated the sole caller (server.py) and tests. 2. Drop the stray "// ponytail:" marker prefix in logger.ts (kept the content). 3. loadBlueprintConfig's child logger now carries user_id (was task_id/repo only) — its log lines are admission→terminal and must join by user_id per AC. 4. Extract envelopeFor(task) → { log, correlation } in orchestrator.ts, the single source for the log child and the event correlation. Replaces the hand-rolled logger.child + EventCorrelation literal duplicated across 5 sites (orchestrate-task, hydrateAndTransition, finalizeTask, failTask, loadBlueprintConfig) — the drift that produced item 3. 5. Comment on the Python ""-vs-None param asymmetry in propagate_correlation_context (mirrors upstream config types). 6. set-compare baggage keys in the observability test instead of exact order (order isn't part of the contract). Tests: envelopeFor unit tests (log child carries user_id incl. repo-less omission); observability/server tests updated for the rename. CDK 2258 pass + synth clean; agent 1182 pass; eslint/ruff/ruff-format/tsc clean. --------- Co-authored-by: bgagent <bgagent@noreply.github.com>
1 parent 9467a94 commit 092361e

23 files changed

Lines changed: 654 additions & 122 deletions

agent/src/observability.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,54 @@ def current_otel_trace_id() -> str | None:
6464
bundle) so operators can join the task to its CloudWatch/X-Ray trace. Returns
6565
``None`` when there is no recording span (e.g. tracing disabled locally) or
6666
the context is invalid, so callers can treat it as a graceful-missing field.
67+
68+
Never raises: a broken/misconfigured tracer degrades to ``None`` rather than
69+
propagating. Callers read this inside DDB-write try-blocks (progress_writer),
70+
where a raised trace error would otherwise be misclassified as a DDB failure
71+
and trip the shared progress circuit breaker.
6772
"""
68-
span = trace.get_current_span()
69-
ctx = span.get_span_context()
70-
if not ctx.is_valid:
73+
try:
74+
span = trace.get_current_span()
75+
ctx = span.get_span_context()
76+
if not ctx.is_valid:
77+
return None
78+
# format_trace_id renders the 128-bit id as zero-padded 32-char hex — the
79+
# OTEL format, so it joins directly in CloudWatch Transaction Search. Note
80+
# the X-Ray console renders trace ids as ``1-{8hex}-{24hex}``; to look this
81+
# up there, transform to that form (the timestamp is the first 8 hex chars).
82+
return trace.format_trace_id(ctx.trace_id)
83+
except Exception:
84+
# nosemgrep: py-silent-success-masking -- trace id is a graceful-missing
85+
# correlation field; a tracer fault must not fail the caller's write path.
7186
return None
72-
# format_trace_id renders the 128-bit id as zero-padded 32-char hex — the
73-
# OTEL format, so it joins directly in CloudWatch Transaction Search. Note
74-
# the X-Ray console renders trace ids as ``1-{8hex}-{24hex}``; to look this
75-
# up there, transform to that form (the timestamp is the first 8 hex chars).
76-
return trace.format_trace_id(ctx.trace_id)
7787

7888

79-
def set_session_id(session_id: str) -> None:
80-
"""Propagate *session_id* via OTEL baggage for AgentCore session correlation.
89+
def propagate_correlation_context(
90+
session_id: str,
91+
user_id: str = "",
92+
# ``user_id`` uses ""-means-absent (Cognito sub, mirrors AgentConfig.user_id
93+
# which is never None); ``repo`` uses None-means-absent (mirrors the optional
94+
# TaskRecord.repo). Both conventions flow from upstream config types; the
95+
# ``if x:`` guards below flatten either to "don't set the baggage key".
96+
repo: str | None = None,
97+
) -> None:
98+
"""Propagate the correlation envelope via OTEL baggage.
99+
100+
*session_id* correlates custom spans to the AgentCore session; *user_id*
101+
and *repo* (#245) carry the platform identity and target repo so baggage
102+
survives across pipeline phases on the task thread. Empty/None fields are
103+
not set — so this runs (and is useful) even when *session_id* is empty but
104+
the identity is known. *repo* is None for repo-less workflows (#248 Phase 3).
81105
82106
The attached context is intentionally not detached: the background thread
83107
runs a single task then exits, so the context is garbage-collected with the
84108
thread.
85109
"""
86-
ctx = baggage.set_baggage("session.id", session_id)
110+
ctx = context.get_current()
111+
if session_id:
112+
ctx = baggage.set_baggage("session.id", session_id, context=ctx)
113+
if user_id:
114+
ctx = baggage.set_baggage("user.id", user_id, context=ctx)
115+
if repo:
116+
ctx = baggage.set_baggage("repo.url", repo, context=ctx)
87117
context.attach(ctx) # token not stored — thread-scoped lifetime

agent/src/pipeline.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,13 +679,18 @@ def run_task(
679679
"repo.url": config.repo_url,
680680
"issue.number": config.issue_number,
681681
"agent.model": config.anthropic_model,
682+
# Correlation envelope (#245): user.id joins agent spans to
683+
# orchestrator logs by the platform identity, not just task/repo.
684+
**({"user.id": config.user_id} if config.user_id else {}),
682685
},
683686
) as root_span:
684687
task_state.write_running(config.task_id)
685688
task_state.write_heartbeat(config.task_id)
686689

687690
agent_result: AgentResult | None = None
688-
progress = _ProgressWriter(config.task_id, trace=trace)
691+
progress = _ProgressWriter(
692+
config.task_id, trace=trace, user_id=config.user_id, repo=config.repo_url
693+
)
689694
# #251: clear any blocker latched by a prior task. The agent container
690695
# is one-task-per-process today, but the FastAPI server thread-pool can
691696
# in principle dispatch a second run_task in the same process — reset

agent/src/progress_writer.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from decimal import Decimal
3939
from typing import Literal
4040

41+
from observability import current_otel_trace_id
42+
4143
# Preview field cap defaults (design §10.1):
4244
# - 200 chars for normal tasks — small DDB rows, cheap watch-stream bytes.
4345
# - 4096 chars (4 KB) for ``--trace`` opt-in tasks — full enough to
@@ -373,8 +375,19 @@ class _ProgressWriter:
373375

374376
_MAX_FAILURES = 3
375377

376-
def __init__(self, task_id: str, trace: bool = False) -> None:
378+
def __init__(
379+
self,
380+
task_id: str,
381+
trace: bool = False,
382+
user_id: str = "",
383+
repo: str | None = None,
384+
) -> None:
377385
self._task_id = task_id
386+
# Correlation envelope (#245): stamped on every event so the agent-side
387+
# event stream joins to orchestrator logs and the X-Ray trace. ``repo``
388+
# is None for repo-less workflows (#248 Phase 3).
389+
self._user_id = user_id
390+
self._repo = repo
378391
self._table_name = os.environ.get("TASK_EVENTS_TABLE_NAME")
379392
self._table = None
380393
# Per-instance preview cap — design §10.1. ``trace=True`` raises
@@ -467,6 +480,11 @@ def _put_event(self, event_type: str, metadata: dict) -> None:
467480
return
468481

469482
now = datetime.now(UTC)
483+
# Correlation envelope (#245): trace_id is read per-event from the
484+
# active span (it may be None early, before the root span opens);
485+
# user_id/repo are stamped when known. All are omitted when
486+
# empty/None so the item shape stays stable for consumers.
487+
trace_id = current_otel_trace_id()
470488
item = {
471489
"task_id": self._task_id,
472490
"event_id": _generate_ulid(),
@@ -478,6 +496,12 @@ def _put_event(self, event_type: str, metadata: dict) -> None:
478496
"timestamp": now.isoformat(),
479497
"ttl": int(now.timestamp()) + _TTL_SECONDS,
480498
}
499+
if self._user_id:
500+
item["user_id"] = self._user_id
501+
if self._repo:
502+
item["repo"] = self._repo
503+
if trace_id:
504+
item["trace_id"] = trace_id
481505
self._table.put_item(Item=item)
482506

483507
# Success: reset the shared failure counter. We do NOT flip

agent/src/runner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,12 @@ def _on_stderr(line: str) -> None:
483483
# invocations we fall back to a fresh writer with no accumulator.
484484
if trajectory is None:
485485
trajectory = _TrajectoryWriter(config.task_id or "unknown")
486-
progress = _ProgressWriter(config.task_id or "unknown", trace=config.trace)
486+
progress = _ProgressWriter(
487+
config.task_id or "unknown",
488+
trace=config.trace,
489+
user_id=config.user_id,
490+
repo=config.repo_url,
491+
)
487492

488493
# Map tool_use_id → tool_name so we can label ToolResultBlocks that arrive
489494
# in UserMessages (ToolResultBlock carries only the id, not the name).

agent/src/server.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import task_state
2727
from config import resolve_github_token
2828
from models import TaskResult
29-
from observability import set_session_id
29+
from observability import propagate_correlation_context
3030
from pipeline import run_task
3131

3232
# --- _debug_cw / _warn_cw failure counter -------------------------------
@@ -452,10 +452,12 @@ def _run_task_background(
452452
hb_thread.start()
453453

454454
try:
455-
# Propagate session ID into this thread's OTEL context so spans
456-
# are correlated with the AgentCore session in CloudWatch.
457-
if session_id:
458-
set_session_id(session_id)
455+
# Propagate the correlation envelope into this thread's OTEL context
456+
# so spans are correlated with the AgentCore session and the platform
457+
# identity in CloudWatch (#245). Runs whenever any field is present —
458+
# session_id may be empty while user_id/repo are known.
459+
if session_id or user_id or repo_url:
460+
propagate_correlation_context(session_id, user_id=user_id, repo=repo_url or None)
459461

460462
run_task(
461463
repo_url=repo_url,

agent/tests/test_observability.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,46 @@ def test_returns_none_for_invalid_context(self):
4444
span.get_span_context.return_value = ctx
4545
with patch.object(observability.trace, "get_current_span", return_value=span):
4646
assert observability.current_otel_trace_id() is None
47+
48+
def test_degrades_to_none_when_tracer_raises(self):
49+
# A broken/misconfigured tracer must not propagate: callers read this
50+
# inside DDB-write try-blocks, where a raise would be misclassified as a
51+
# DDB failure and trip the shared progress circuit breaker (#245 review).
52+
with patch.object(
53+
observability.trace, "get_current_span", side_effect=RuntimeError("tracer boom")
54+
):
55+
assert observability.current_otel_trace_id() is None
56+
57+
58+
class TestPropagateCorrelationContext:
59+
"""``propagate_correlation_context`` propagates the correlation envelope
60+
(#245) via OTEL baggage, setting only the fields that are present."""
61+
62+
def test_sets_all_envelope_fields_when_present(self):
63+
with (
64+
patch.object(observability, "baggage") as bag,
65+
patch.object(observability, "context") as ctx,
66+
):
67+
bag.set_baggage.return_value = "CTX"
68+
observability.propagate_correlation_context("sess-1", user_id="user-1", repo="org/repo")
69+
# Baggage-key ordering is not part of the contract — compare as a set.
70+
keys = {c.args[0] for c in bag.set_baggage.call_args_list}
71+
assert keys == {"session.id", "user.id", "repo.url"}
72+
ctx.attach.assert_called_once()
73+
74+
def test_omits_absent_fields(self):
75+
# Empty user_id and None repo → only session.id is set on the baggage.
76+
with patch.object(observability, "baggage") as bag, patch.object(observability, "context"):
77+
bag.set_baggage.return_value = "CTX"
78+
observability.propagate_correlation_context("sess-1")
79+
keys = {c.args[0] for c in bag.set_baggage.call_args_list}
80+
assert keys == {"session.id"}
81+
82+
def test_empty_session_id_is_not_stamped(self):
83+
# Reachable via server.py's widened trigger (user_id known, no session):
84+
# an empty session_id must not write an empty-string session.id baggage.
85+
with patch.object(observability, "baggage") as bag, patch.object(observability, "context"):
86+
bag.set_baggage.return_value = "CTX"
87+
observability.propagate_correlation_context("", user_id="user-1")
88+
keys = {c.args[0] for c in bag.set_baggage.call_args_list}
89+
assert keys == {"user.id"}

agent/tests/test_progress_writer.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,59 @@ def test_ttl_is_90_days_from_now(self, writer, mock_table):
267267
assert before + ttl_90_days <= item["ttl"] <= after + ttl_90_days + 1
268268

269269

270+
# ---------------------------------------------------------------------------
271+
# _ProgressWriter — correlation envelope (#245)
272+
# ---------------------------------------------------------------------------
273+
274+
275+
class TestCorrelationEnvelope:
276+
"""Every emitted event stamps {user_id, repo, trace_id} when known, so the
277+
agent-side event stream joins to orchestrator logs and the X-Ray trace."""
278+
279+
def _writer(self, monkeypatch, **kwargs):
280+
monkeypatch.setenv("TASK_EVENTS_TABLE_NAME", "events-table")
281+
writer = _ProgressWriter("task-42", **kwargs)
282+
writer._table = MagicMock()
283+
return writer
284+
285+
def test_stamps_user_repo_trace_when_present(self, monkeypatch):
286+
writer = self._writer(monkeypatch, user_id="user-1", repo="org/repo")
287+
with patch("progress_writer.current_otel_trace_id", return_value="a" * 32):
288+
writer.write_agent_milestone("m", "")
289+
item = writer._table.put_item.call_args[1]["Item"]
290+
assert item["user_id"] == "user-1"
291+
assert item["repo"] == "org/repo"
292+
assert item["trace_id"] == "a" * 32
293+
294+
def test_repo_less_omits_repo(self, monkeypatch):
295+
# repo-less workflow (#248 Phase 3): repo is None → key omitted, not null.
296+
writer = self._writer(monkeypatch, user_id="user-1", repo=None)
297+
with patch("progress_writer.current_otel_trace_id", return_value="b" * 32):
298+
writer.write_agent_milestone("m", "")
299+
item = writer._table.put_item.call_args[1]["Item"]
300+
assert item["user_id"] == "user-1"
301+
assert "repo" not in item
302+
303+
def test_missing_trace_id_omits_key(self, monkeypatch):
304+
# No recording span (tracing disabled) → trace_id key omitted entirely.
305+
writer = self._writer(monkeypatch, user_id="user-1", repo="org/repo")
306+
with patch("progress_writer.current_otel_trace_id", return_value=None):
307+
writer.write_agent_milestone("m", "")
308+
item = writer._table.put_item.call_args[1]["Item"]
309+
assert "trace_id" not in item
310+
311+
def test_defaults_omit_all_correlation_keys(self, monkeypatch):
312+
# Back-compat: a writer built without the envelope (empty user_id) stamps
313+
# none of the optional keys, keeping the historical item shape.
314+
writer = self._writer(monkeypatch)
315+
with patch("progress_writer.current_otel_trace_id", return_value=None):
316+
writer.write_agent_milestone("m", "")
317+
item = writer._table.put_item.call_args[1]["Item"]
318+
assert "user_id" not in item
319+
assert "repo" not in item
320+
assert "trace_id" not in item
321+
322+
270323
# ---------------------------------------------------------------------------
271324
# _ProgressWriter — --trace preview cap (design §10.1)
272325
# ---------------------------------------------------------------------------

agent/tests/test_server.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,41 @@ def fake_run_task(**_kwargs):
288288
assert heartbeat_calls[0] == "t-heartbeat"
289289

290290

291+
def test_run_task_background_propagates_correlation_envelope(monkeypatch):
292+
"""The background task thread propagates {session_id, user_id, repo} into
293+
OTEL baggage via propagate_correlation_context (#245).
294+
295+
Regression guard for the widened trigger: correlation must propagate even
296+
when session_id is empty but user_id/repo are known — the branch the whole
297+
envelope-in-baggage feature depends on.
298+
"""
299+
calls: list[dict] = []
300+
monkeypatch.setattr(
301+
server,
302+
"propagate_correlation_context",
303+
lambda session_id, **kw: calls.append({"session_id": session_id, **kw}),
304+
)
305+
monkeypatch.setattr(server, "run_task", lambda **_kwargs: None)
306+
monkeypatch.setattr(server.task_state, "write_heartbeat", lambda *a, **kw: None)
307+
monkeypatch.setattr(server.task_state, "write_terminal", lambda *a, **kw: None)
308+
309+
# No session_id, but user_id + repo_url known → propagation must still run.
310+
server._run_task_background(
311+
task_id="t-corr",
312+
repo_url="o/r",
313+
task_description="x",
314+
issue_number="",
315+
github_token="",
316+
anthropic_model="",
317+
max_turns=10,
318+
max_budget_usd=None,
319+
aws_region="us-east-1",
320+
user_id="user-1",
321+
)
322+
323+
assert calls == [{"session_id": "", "user_id": "user-1", "repo": "o/r"}]
324+
325+
291326
def test_validate_required_params_pr_workflows_require_pr_number():
292327
"""PR-iteration and PR-review workflows need a pr_number regardless."""
293328
missing = server._validate_required_params(

cdk/src/handlers/get-task-events.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,18 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
219219
});
220220
}
221221

222-
// 6. Strip task_id from event records (redundant in response context)
222+
// 6. Strip task_id from event records (redundant in response context).
223+
// Pass through the correlation envelope (#245) per-event, omitting each
224+
// field when the source didn't stamp it (e.g. task_created) so the shape
225+
// stays stable for consumers.
223226
const eventData = events.map(e => ({
224227
event_id: e.event_id,
225228
event_type: e.event_type,
226229
timestamp: e.timestamp,
227230
metadata: e.metadata ?? {},
231+
...(e.user_id && { user_id: e.user_id }),
232+
...(e.repo && { repo: e.repo }),
233+
...(e.trace_id && { trace_id: e.trace_id }),
228234
}));
229235

230236
// For descending scans we intentionally suppress ``next_token``. DDB's

cdk/src/handlers/get-task-replay.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ export function assembleBundle(
197197
event_type: e.event_type,
198198
timestamp: e.timestamp,
199199
metadata: e.metadata ?? {},
200+
// Correlation envelope (#245): passed through per-event, omitted when absent.
201+
...(e.user_id && { user_id: e.user_id }),
202+
...(e.repo && { repo: e.repo }),
203+
...(e.trace_id && { trace_id: e.trace_id }),
200204
}));
201205

202206
// Verification is non-null only when at least one gate result was persisted.

0 commit comments

Comments
 (0)