Skip to content

feat(observability): correlate HTTP enforcement with traces and OCSF - #5214

Open
afourniernv wants to merge 3 commits into
NVIDIA:mainfrom
afourniernv:codex/relay-state-01-correlation-primitives
Open

afourniernv wants to merge 3 commits into
NVIDIA:mainfrom
afourniernv:codex/relay-state-01-correlation-primitives

Conversation

@afourniernv

@afourniernv afourniernv commented Nov 3, 2026 •

Copy link
Copy Markdown

Summary

Connect an inspected HTTP request to its OpenShell policy span, OCSF record and response receipt. This replaces the primitive-only scope with the combined HTTP core, built on merged #4789.

Related Issue

Closes #5393. Tracking: #5210, which remains open for the remaining correlation layers. The component issue needs maintainer acceptance; this PR does not close the broader tracking issue.

Changes

  • Accept bounded W3C context as untrusted correlation data. Share an OpenShell-generated request ID with middleware context, OCSF metadata.correlation_uid and the protected X-OpenShell-Request-ID response header.
  • Add an openshell.http.request root and policy-evaluation child. Emit one primary HTTP Activity record when response headers are accepted, with a fallback for earlier failure or cancellation.
  • Reuse feat(ocsf): correlate audit events with sampled OpenTelemetry traces #4789's native OCSF trace profile, sampled log context and legacy deny spans. Avoid duplicate deny spans on inspected HTTP paths.

No new gateway RPC, protobuf field or Relay dependency. The receipt is not authorization or proof of complete delivery; L4 and uninspected passthrough traffic do not provide it. Middleware, credentials, revisions, MCP and terminal delivery remain separate layers, not part of this diff.

Testing

  • HTTP-core mise run pre-commit, including strict workspace and standalone clippy.
  • HTTP-core network suite: 1,623 passed, two existing ignored tests, no added skips.
  • Retained context-validation, receipt-integrity, single-record and upstream classification coverage.
  • Fresh cumulative Hermes/Relay validation: 13 workflows, 1,552 spans and 433 joined OCSF records, including protected synthetic APIs, authenticated GitHub, denials, mutation, policy reload, incomplete delivery and collector rejection. Native model/tool spans and receipt marks retain their original parents. This validates the cumulative prototype, not this core in isolation.
  • The cumulative stack's 1,684 network, 134 middleware, 68 OTel, 188 OCSF and 84 relay tests passed. The canonical mise run test rerun passed with four test threads. An initial unchanged VM cleanup fixture flaked; its isolated reruns and all 274 VM tests passed in the final run. Full mise run ci is not claimed.

Checklist

  • Conventional Commits.
  • DCO sign-offs preserved, including the review-history merge.
  • Crate and published correlation documentation updated.

The review-history merge preserves the old draft branch without rewriting it. Its file tree is identical to the HTTP core, directly based on merged main.

Tested upstream snapshot: 638974575 (merged #4789). Main has since advanced and GitHub currently reports conflicts. Ready for review does not mean merge-ready; the current code has not been retested on today's main.

Signed-off-by: Alex Fournier <afournier@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Nov 3, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@sftimeless

Copy link
Copy Markdown

Hey, thanks for working on this.

The separation between correlation context and authorization identity is important.

I've been testing Keel as an external pre-execution authorization service through OpenShell's Supervisor Middleware, and this connects directly to an issue we encountered during our v0.1.3 qualification.

In that experiment, the adapter could associate RequestContext.request_id with a signed Keel permit, but the corresponding supervisor OCSF event did not carry that identifier. We documented the ALLOW/DENY examples in #4557.

I see this PR introduces OcsfEvent::set_correlation_uid and the OpenTelemetry correlation helpers, while leaving live HTTP enforcement and middleware integration for subsequent work.

For that next integration step, would the intended approach be to propagate the supervisor-generated RequestContext.request_id into metadata.correlation_uid on the actual middleware enforcement events, as proposed in #4557?

That would let an external authorization provider join:

  • Its signed authorization decision and permit ID
  • The middleware request ID
  • OpenShell's observed enforcement result

It would also be useful to preserve that identifier across request and response evaluation, including denials and middleware failures.

I'm not suggesting that correlation alone proves exact-request binding or downstream effects. Those remain separate guarantees.

Happy to help validate the eventual runtime integration against our existing sanitized test cases if helpful.

… record

Signed-off-by: Alex Fournier <afournier@nvidia.com>
Preserve the prior primitive-only review history while adopting the tested HTTP core built on merged NVIDIA#4789.

Signed-off-by: Alex Fournier <afournier@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Nov 6, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@afourniernv afourniernv changed the title feat(observability): add enforcement correlation primitives feat(observability): correlate HTTP enforcement with traces and OCSF Nov 6, 2026
@krishicks

Copy link
Copy Markdown
Collaborator

Written by Claude Code, reviewed by @krishicks

A question about span volume. Every inspected request gets openshell.http.request and openshell.policy.evaluate, and the queued layers add more INFO children. Supervisors use the SDK's default parentbased_always_on sampler, because OTEL_TRACES_SAMPLER doesn't reach them, and an unsampled workload traceparent can't suppress these spans. So every inspected request is exported. Inspected denials also move from supervisor.egress.deny, which #4789 bounded with per-destination and process-wide budgets, to request spans with no bound.

We see supervisor traces as a tool for latency and errors, sampled like any other service, rather than a full record of every request. OCSF already provides that record, and request rates belong in metrics. Sampling for supervisors is tracked in #5362, and supervisor metrics in #5364. In the meantime, we'd like these spans not to assume they're always exported. In particular, could you confirm that the request ID join through metadata.correlation_uid and the response header works with no sampled span? Also, when supervisor.egress.deny gives way to request spans, the request spans should stay inside whatever bound #4789 provides until sampling exists.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Compatibility review (advisory)

New or updated breaking changes; see the full report for all findings.

1. api-behavior — No explicit risk acceptance found

Successful HTTP FORWARD requests without an L7 route now emit a fallback HTTP Activity record instead of the existing Allowed/Success record with the requested path and process.

  • Impact: OCSF consumers filtering action=Allowed or attributing requests through actor.process lose successful requests and their original request details.
  • Action: Populate the correlated FORWARD record with the existing action, status, path, process and endpoint fields before response-head emission.
  • PR: feat(observability): correlate HTTP enforcement with traces and OCSF #5214

2. api-behavior — No explicit risk acceptance found

x-openshell-request-id previously passed through HTTP forwarding and allowed middleware mutations; the candidate strips incoming values, replaces upstream values and rejects mutations.

  • Impact: Existing middleware writing or removing this header fails closed by default, while applications using it for end-to-end request correlation lose their identifiers.
  • Action: Keep legacy header handling on v0.1.8 and make protected receipts opt-in, documenting a separate header for application-owned correlation.
  • PR: feat(observability): correlate HTTP enforcement with traces and OCSF #5214

3. api-behavior — No explicit risk acceptance found

Inspected HTTP denials replace bounded supervisor.egress.deny spans with unbounded openshell.http.request and policy spans that can inherit workload traceparent.

Review details
  • Execution: complete
  • Candidate: PR #5214 (37a7bef8c689b4e3ea34b2f839fa2bd70f306472)
  • Baseline: 9321d1632a41cd7337db38ff982164bff9120d08 (9321d1632a41cd7337db38ff982164bff9120d08)
  • Reviewed: rust-sdk, python-sdk, go-sdk, typescript-sdk, api-behavior, cli, configuration, policy, helm, state

Advisory review; publication gates are unchanged.

Branch check run

@krishicks krishicks 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.

Written by Claude Code, reviewed by @krishicks

Reviewed at 7e8f7ae. Each point starts with what a user would see; the cause and file references follow. Line numbers refer to that commit.

Audit records

  1. A SIEM shows requests the supervisor blocked as allowed. A request blocked by a middleware failure (403), admission exhaustion (503), a WebSocket preflight error (502), or a credential preparation failure (502) gets one HTTP Activity record that says Allowed, with no failure status.
    • Cause: emit_primary reclassifies the record only when the outcome is denied (l7/correlation.rs:212-220). The platform_error and upstream_error outcomes keep the allow record (relay.rs:900-904, relay.rs:1823-1855).
  2. Allowed plain-HTTP proxy requests no longer say which process made them. On main, every allowed FORWARD request without L7 inspection is recorded with its path, binary, PID, and cmdline. With this PR, it gets a placeholder record: "HTTP request ended before a response was selected", URL /, no action, no process.
    • Cause: the correlation is created up front (proxy.rs:5921-5926), so the empty slot is emitted at the response head. The real allow record arrives after the relay (proxy.rs:7228) and is dropped (correlation.rs:174).
  3. Requests rejected before policy runs are reported as policy denials. An Upgrade refusal, a malformed request target, or an invalid GraphQL or JSON-RPC body produces "HTTP request blocked after policy admission", URL /, and a span with policy.decision=deny. Policy never ran on these requests, so an operator investigating policy is sent to the wrong place.
    • Cause: these paths emit only a Network Activity record, so the placeholder record is emitted and relabelled as a denial (relay.rs:1310-1335, relay.rs:1550-1665, relay.rs:3344-3348).
  4. Some requests still produce extra HTTP Activity records that can't be joined by request ID, and one produces two records that disagree. This happens to any request with response middleware, a request-middleware denial, or a token-grant refusal. A token-grant refusal gives one record saying Denied and another saying Allowed with a 502. Denials on these paths also open a separate supervisor.egress.deny span next to the request span.
    • Cause: these paths still emit through telemetry::emit_decision without correlation_uid (http_response.rs:698-1251, middleware.rs:1023, token_grant_injection.rs:344). The request ID is already available at the call sites (relay.rs:1810, relay.rs:2655, relay.rs:3138).
  5. When credential resolution or MCP reinspection rejects a request, the record no longer shows which rule matched. The surviving record loses the request path, policy rule, and reason. A problem after the response headers are sent produces no correlated record at all.
    • Cause: emit() replaces the primary record with any later HTTP Activity, and silently drops one that arrives after the head is written (correlation.rs:166-176).

Trust and sampling

  1. A sandboxed workload decides which trace ID appears on supervisor audit records. If it sends a sampled traceparent, the HTTP Activity record's trace.uid is the workload's trace ID. A workload could point its denials at an unrelated trace. Once #5362 adds a sampling ratio, it could also force its request spans to be exported regardless of that ratio.
    • Cause: a sampled workload context becomes the parent of openshell.http.request (propagation.rs:169-174). #4789 documents that workload headers never supply supervisor correlation, and #2640 keeps workload context in a separate field.
    • Linking sampled context the way unsampled context is already linked would keep the connection to the agent's trace without either effect.

Behavior changes beyond correlation

These change what clients see. Could they move to a separate PR, or at least be listed in the PR description?

  1. Agent HTTP clients stop retrying, so a routine connection close becomes a failed request. When an upstream closes an idle keep-alive connection, main closes the client connection. Go, urllib3, and curl retry that automatically. With this PR the client gets a 502 and surfaces the error.
    • Cause: a synthetic 502 on upstream EOF before headers (http_response.rs:100-110).
  2. Some upstream APIs that work today stop working. For example, a server that sends HTTP/1.1 200 without a reason phrase now has its response rejected, and the agent sees the connection close. The audit record still reports a 502.
    • Cause: new strict response-head validation that applies whether or not a request is correlated (http_response.rs:425-440). httparse accepts a missing reason phrase.
  3. Agents get a 500 response where the connection used to close. This happens on a missing credential or a stale credential generation.
    • Cause: relay.rs:1064-1080, proxy.rs:6851-6880.

Spans

  1. Traces don't show where an inspected request's time went. Only policy evaluation appears under openshell.http.request. Middleware calls, credential preparation, and the upstream dial are not children of the request span, and remote middleware receives the connection's trace context instead of the request's.
  2. WebSocket requests show up as a single span lasting the whole session. The root span stays open until the upgraded connection closes.
  3. The span marks an error but not which stage failed. record_error_stage ignores its stage argument (correlation.rs:162).

Tests

Most of the record problems above would have been caught by a test asserting exactly one HTTP Activity per request. The dispatch tests use .find() instead. These paths have no coverage:

  • placeholder records and rejections before policy runs
  • the content of a non-L7 FORWARD record
  • token-grant refusal
  • request-middleware denial
  • upstream EOF before headers
  • a second request on a keep-alive connection, which should get a new ID

Process

The branch conflicts with main.

@krishicks

krishicks commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

This is what the robot drew when I asked for a before/after with this PR:

5214-before 5214-after

@krishicks

krishicks commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Written by Claude Code, reviewed by @krishicks

A follow-up connecting the review with my earlier comment on span volume. The compatibility review's item 3 raises the same concern: span volume. Review point 10 asks for a child span per stage, which adds spans. Nothing bounds that until #5362 adds supervisor sampling, and the #4789 deny-span limiter isn't the right tool, since #5362 removes it.

Could the request spans land with or after #5362? This PR would keep the request ID, correlation_uid, the response header, and the primary record. Those don't depend on spans. That also needs the request ID join to work with no sampled span, which the earlier comment asked you to confirm.

Then the after diagram would look like this:
5214-after-review

Copy link
Copy Markdown
Contributor

Drafted with Codex; reviewed and approved by @HarryMoss.

Hi @afourniernv, I’ve read Kris’s latest review and the proposed split between the correlation core and request spans.

My local #2640 connection-startup prototype is based on #5214 at 7e8f7aec and touches the shared CONNECT/proxy and telemetry paths. I’ll keep the L4 span work local pending #5362.

Once you’ve settled the split, which revision should I track for those shared paths so I can avoid duplicating your fixes?

Context: #2640 (comment)

This branch has not been deployed

No deployments
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.

feat(observability): correlate inspected HTTP requests with traces and OCSF

4 participants