Skip to content

feat: reconcile-to-request trace correlation via span links - #207

Open
JuanmaBM wants to merge 2 commits into
openshift-online:mainfrom
JuanmaBM:feat/reconcile-trace-correlation
Open

feat: reconcile-to-request trace correlation via span links#207
JuanmaBM wants to merge 2 commits into
openshift-online:mainfrom
JuanmaBM:feat/reconcile-trace-correlation

Conversation

@JuanmaBM

Copy link
Copy Markdown
Collaborator

Summary

  • Persist W3C traceparent/tracestate on every resource at create/update time via a TraceMeta embeddable struct (json:"-" keeps fields out of REST responses)
  • Propagate trace context through gRPC ObjectReference (proto fields 6-7) so the control plane receives it on watch events
  • Control plane attaches OTel span links on reconcile spans, enabling Jaeger navigation from any reconciliation back to the originating API request

Changes

API Server

  • pkg/api/tracemeta.goTraceMeta struct with CaptureTraceContext(ctx) method
  • proto/common.protooptional string traceparent/tracestate on ObjectReference
  • All 7 plugins (fleets, gateways, gatewayNetworks, gatewayReleases, managedClusters, managedDatabases, roleBindings):
    • Model: embed TraceMeta
    • Migration: ALTER TABLE ADD COLUMN IF NOT EXISTS traceparent/tracestate
    • Service: CaptureTraceContext(ctx) before dao.Create/dao.Replace
    • gRPC presenter: map Traceparent/Tracestate to ObjectReference

Control Plane

  • StartReconcileSpan accepts traceparent string, creates span link via parseTraceparentLink
  • All 10 reconciler call sites updated (event-driven pass traceparent, continuous reconcilers pass "")

Spec

  • specs/platform/reconcile-trace-correlation.spec.md — 5 requirements (RTC-01 through RTC-05)

Test plan

  • go build ./... and go vet ./... clean on both API server and control plane
  • Control plane OTel unit tests pass (12 tests including 10 traceparent parsing cases)
  • API server integration tests pass (fleets, gatewayNetworks, gatewayReleases, managedClusters, roleBindings, roles, serviceAccounts)
  • Deploy to dev cluster and verify span links visible in Jaeger

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d1a589db-7f42-4114-ad81-374a6c18ad6d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

Persist W3C traceparent/tracestate on every resource at create/update
time, propagate through gRPC ObjectReference, and attach OTel span
links on control-plane reconcile spans so Jaeger can navigate from a
reconciliation back to the originating API request.

API server:
- TraceMeta embeddable struct (json:"-", gorm columns)
- Proto: traceparent/tracestate on ObjectReference (fields 6-7)
- All 7 plugins: model embed, migration, service injection, gRPC presenter
- Service layer calls CaptureTraceContext(ctx) before dao.Create/Replace

Control plane:
- StartReconcileSpan accepts traceparent, creates span link via parseTraceparentLink
- All 10 reconciler call sites updated (event-driven pass traceparent, continuous pass "")
- 12 unit tests for traceparent parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@JuanmaBM
JuanmaBM force-pushed the feat/reconcile-trace-correlation branch from 57e3635 to f43d2aa Compare August 27, 2026 07:33
Add table-driven tests for CaptureTraceContext covering valid/invalid
span contexts, tracestate propagation, unsampled flags, and idempotency.
Document traceparent and tracestate fields in the ObjectReference proto
message with W3C Trace Context format and emptiness semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@JuanmaBM
JuanmaBM force-pushed the feat/reconcile-trace-correlation branch from f43d2aa to de18e0b Compare August 27, 2026 07:48
@jsell-rh

jsell-rh commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Amber review

Status: Complete

Verdict

COMMENT — This is a clean, well-scoped, spec-backed observability feature. Trace context is captured from the active span at persistence time, kept out of REST responses (json:"-"), flows over gRPC via ObjectReference, and is attached as a best-effort span link (not a parent-child edge) on reconcile roots. No blockers: no panic(), robust parsing with silent fallback, additive nullable columns with a rollback, and solid table-driven tests. A few minor items and cross-PR coordination are noted below.

Amber Analysis

The design is correct for the asynchronous reconcile problem: a span link preserves causal reference without implying temporal containment, and the reconcile span stays an independently-sampled root (RTC-03 / CP-OBS-02). Error handling is exemplary — parseTraceparentLink validates part count, hex length, and zero IDs, and any malformed/empty value degrades silently to a normal root with no error, exactly as RTC-03 requires. Secrets/privacy are respected: traceparent/tracestate are opaque hex identifiers, excluded from REST via json:"-" (RTC-05), and never logged. Migration IDs are unique per plugin (20260825000000010007), so there is no gormigrate collision, and each migration is reversible.

Confidence: High on the correctness of the parsing/link logic and security posture; Medium on the update-path nulling behavior (depends on the DAO's Replace semantics, which I could not execute — Go toolchain unavailable in the review sandbox).

Findings

1. [Minor] Spec says "a single gormigrate migration"; implementation ships seven per-plugin migrationsSpec Consistency
specs/platform/reconcile-trace-correlation.spec.md:21 (RTC-01) states "A single gormigrate migration SHALL add the columns to all resource tables," but the implementation adds an independent migrationAddTraceContext() to each of the 7 plugins. The per-plugin approach is defensible (plugins own their schema, IDs stay unique), but the spec text and the code disagree. Reconcile one to the other so the spec remains the source of truth.

2. [Minor] Server-initiated RoleBinding creation paths bypass CaptureTraceContextCompleteness
plugins/roleBindings/service.go calls rb.CaptureTraceContext(ctx) in Create, but CreateGatewayOwnerBinding and SyncJWTRoles persist bindings via the DAO directly and will store NULL trace context. That is acceptable (these are internal, non-request flows with no meaningful originating request span), but worth confirming it is intentional so those reconciles are knowingly link-less.

3. [Minor] Update while telemetry is disabled may null a previously-stored traceparentCorrectness (low confidence)
On Replace, CaptureTraceContext leaves the pointer fields nil when no valid span is active. Depending on whether the DAO's Replace uses gorm Save (writes nil → NULL) versus Updates with a struct (skips zero values), an update performed with telemetry off could wipe a prior trace context. RTC-01's "overwrite on update" plus "NULL when telemetry disabled" arguably permits this, but please confirm the intended behavior since it is a silent data change on an existing column.

Positives worth noting: parseTraceparentLink rejects all-zero IDs and bad hex; TestParseTraceparentLink and TestCaptureTraceContext cover the meaningful edge cases including unsampled flags and idempotency; the only modified pre-existing test (otel_test.go) merely threads the new "" argument through — no weakened assertion or removed guarantee (Test Diff Scrutiny: clean).

Cross-PR coordination

I reviewed the other open PRs in openshift-online/hypershell (titles, bodies, changed-file lists, and relevant diffs). Material items requiring maintainer coordination:

No other open PR (UI/web-console, e2e, kind/deps) intersects the trace-correlation data model, proto, or reconcile-span interface.

Findings Summary (ordered by severity, highest first)

  1. [Minor] Spec claims a single migration; code ships 7 per-plugin migrations — Spec Consistency (spec L21)
  2. [Minor] Server-initiated RoleBinding creation paths bypass CaptureTraceContextCompleteness (service.go L197)
  3. [Minor] Update with telemetry disabled may null a stored traceparent (DAO-dependent) — Correctness (tracemeta.go L21)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors handled without failing reconcile (silent fallback per RTC-03) Pass
No secrets in logs or responses (json:"-", opaque IDs) Pass
Input validated (traceparent part/hex/zero-ID checks) Pass
Reconcile span remains root + span link, not parent-child Pass
DB migration reversible + nullable columns for pre-existing rows Pass
Migration IDs unique across plugins Pass
Test Diff Scrutiny (no weakened pre-existing assertions) Pass
Proto/generated .pb.go regenerated, not hand-edited Pass (assumed; toolchain unavailable to re-verify)
Spec matches implementation Fail (single vs per-plugin migration)

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

COMMENT — This is a clean, well-scoped, spec-backed observability feature. Trace context is captured from the active span at persistence time, kept out of REST responses (json:"-"), flows over gRPC via ObjectReference, and is attached as a best-effort span link (not a parent-child edge) on reconcile roots. No blockers: no panic(), robust parsing with silent fallback, additive nullable columns with a rollback, and solid table-driven tests. A few minor items and cross-PR coordination are noted below.

Amber Analysis

The design is correct for the asynchronous reconcile problem: a span link preserves causal reference without implying temporal containment, and the reconcile span stays an independently-sampled root (RTC-03 / CP-OBS-02). Error handling is exemplary — parseTraceparentLink validates part count, hex length, and zero IDs, and any malformed/empty value degrades silently to a normal root with no error, exactly as RTC-03 requires. Secrets/privacy are respected: traceparent/tracestate are opaque hex identifiers, excluded from REST via json:"-" (RTC-05), and never logged. Migration IDs are unique per plugin (20260825000000010007), so there is no gormigrate collision, and each migration is reversible.

Confidence: High on the correctness of the parsing/link logic and security posture; Medium on the update-path nulling behavior (depends on the DAO's Replace semantics, which I could not execute — Go toolchain unavailable in the review sandbox).

Findings

1. [Minor] Spec says "a single gormigrate migration"; implementation ships seven per-plugin migrationsSpec Consistency
specs/platform/reconcile-trace-correlation.spec.md:21 (RTC-01) states "A single gormigrate migration SHALL add the columns to all resource tables," but the implementation adds an independent migrationAddTraceContext() to each of the 7 plugins. The per-plugin approach is defensible (plugins own their schema, IDs stay unique), but the spec text and the code disagree. Reconcile one to the other so the spec remains the source of truth.

2. [Minor] Server-initiated RoleBinding creation paths bypass CaptureTraceContextCompleteness
plugins/roleBindings/service.go calls rb.CaptureTraceContext(ctx) in Create, but CreateGatewayOwnerBinding and SyncJWTRoles persist bindings via the DAO directly and will store NULL trace context. That is acceptable (these are internal, non-request flows with no meaningful originating request span), but worth confirming it is intentional so those reconciles are knowingly link-less.

3. [Minor] Update while telemetry is disabled may null a previously-stored traceparentCorrectness (low confidence)
On Replace, CaptureTraceContext leaves the pointer fields nil when no valid span is active. Depending on whether the DAO's Replace uses gorm Save (writes nil → NULL) versus Updates with a struct (skips zero values), an update performed with telemetry off could wipe a prior trace context. RTC-01's "overwrite on update" plus "NULL when telemetry disabled" arguably permits this, but please confirm the intended behavior since it is a silent data change on an existing column.

Positives worth noting: parseTraceparentLink rejects all-zero IDs and bad hex; TestParseTraceparentLink and TestCaptureTraceContext cover the meaningful edge cases including unsampled flags and idempotency; the only modified pre-existing test (otel_test.go) merely threads the new "" argument through — no weakened assertion or removed guarantee (Test Diff Scrutiny: clean).

Cross-PR coordination

I reviewed the other open PRs in openshift-online/hypershell (titles, bodies, changed-file lists, and relevant diffs). Material items requiring maintainer coordination:

  • #151 spec(control-plane): gate gateway re-provisioning, #179 fix(control-plane): reconcile existing Keycloak clients, #194 feat(control-plane): adopt upstream OpenShell Helm chartCompeting interface change + change-order. This PR changes the signature of the shared helper cpotel.StartReconcileSpan(ctx, kind, eventType) → (…, traceparent) and updates all 10 call sites, and it rewrites the top of GatewayReconciler.Handle to pass gw.GetMetadata().GetTraceparent(). All three of those PRs also modify GatewayReconciler.Handle (and #179/#194 also touch reconciler/health.go) around the same region. None of them currently adopt the new 4-argument signature. Decision needed: whichever merges after #207 must update its StartReconcileSpan call sites (and any newly-added reconcile spans, e.g. #194's new internal/gateway package) to the 4-arg form; whichever merges first forces #207 to rebase its call sites. The designs are compatible — this is a merge-order and interface-adoption decision, not a design clash.

  • #151 (gateways plugin data model)Additive overlap, compatible. Both #151 and #207 add a field + migration to the gateways plugin (model.go, migration.go, plugin.go registration, grpc_presenter.go) and edit the proto layer (#207: common.proto/ObjectReference; #151: gateways.proto/Gateway). Migration IDs are distinct (2026082500000002 vs 2026081912000006) and the added fields are different, so there is no logical or schema conflict — only textual merge coordination in the shared presenter/model files.

  • #185 control-plane-world-sync, #200 control-plane reconciliation contract, #148 (branch-build spec)No material conflict. #185/#148/#207 each append a row to specs/index.spec.md (trivial textual merge only). Conceptually #185's periodic world-sync is consistent with #207's design: after a resync-triggered reconcile, the span link still points to the last user mutation's trace, which is #207's intended overwrite-on-update semantics. #200 defines a reconciliation contract but does not touch the observability specs or the span-link mechanism. No design decision required.

No other open PR (UI/web-console, e2e, kind/deps) intersects the trace-correlation data model, proto, or reconcile-span interface.

Findings Summary (ordered by severity, highest first)

  1. [Minor] Spec claims a single migration; code ships 7 per-plugin migrations — Spec Consistency (spec L21)
  2. [Minor] Server-initiated RoleBinding creation paths bypass CaptureTraceContextCompleteness (service.go L197)
  3. [Minor] Update with telemetry disabled may null a stored traceparent (DAO-dependent) — Correctness (tracemeta.go L21)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors handled without failing reconcile (silent fallback per RTC-03) Pass
No secrets in logs or responses (json:"-", opaque IDs) Pass
Input validated (traceparent part/hex/zero-ID checks) Pass
Reconcile span remains root + span link, not parent-child Pass
DB migration reversible + nullable columns for pre-existing rows Pass
Migration IDs unique across plugins Pass
Test Diff Scrutiny (no weakened pre-existing assertions) Pass
Proto/generated .pb.go regenerated, not hand-edited Pass (assumed; toolchain unavailable to re-verify)
Spec matches implementation Fail (single vs per-plugin migration)


The API server SHALL capture the W3C Trace Context (`traceparent` header value, and `tracestate` when present) from the inbound request context on every create and update write, and SHALL persist both values on the resource row in PostgreSQL. The trace context SHALL be stored as plain text columns (`traceparent` and `tracestate`) on the shared `api.Meta` base, so every resource type inherits the field without per-plugin schema changes.

Because the `api.Meta` base struct is defined in the upstream `rh-trex-ai` framework and cannot be modified in-tree, the trace context columns SHALL be added via a local embeddable struct (for example `TraceMeta`) that each resource model embeds alongside `api.Meta`. A single gormigrate migration SHALL add the columns to all resource tables.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RTC-01 states "A single gormigrate migration SHALL add the columns to all resource tables," but the implementation registers a separate migrationAddTraceContext() in each of the 7 plugins. The per-plugin approach is reasonable (plugins own their schema and the IDs stay unique), but please reconcile the spec text with the code so the spec stays authoritative.

return nil, errors.Forbidden("platform:admin can only be assigned via Keycloak")
}

rb.CaptureTraceContext(ctx)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

CaptureTraceContext is called here in Create, but the server-initiated paths CreateGatewayOwnerBinding and SyncJWTRoles persist bindings via the DAO directly and will store NULL trace context. That is probably fine (no meaningful originating request span), but worth confirming it is intentional so those reconciles are knowingly link-less.


// CaptureTraceContext extracts the active span's W3C traceparent and
// tracestate from ctx and stores them. When no valid span is active (OTel
// disabled or no sampled span), the fields are left nil.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On Replace, when no valid span is active (telemetry disabled), these pointers stay nil. Depending on whether the DAO's Replace uses gorm Save (writes nil -> NULL) vs Updates with a struct (skips zero values), an update performed with telemetry off could null out a previously-stored traceparent. RTC-01's overwrite-on-update arguably permits this, but please confirm the intended behavior since it is a silent change to existing data.

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.

2 participants