Skip to content

feat(ocsf): correlate audit events with sampled OpenTelemetry traces - #4789

Draft
HarryMoss wants to merge 5 commits into
NVIDIA:mainfrom
HarryMoss:feat/2640-ocsf-trace-correlation-integration/HarryMoss
Draft

HarryMoss wants to merge 5 commits into
NVIDIA:mainfrom
HarryMoss:feat/2640-ocsf-trace-correlation-integration/HarryMoss

Conversation

@HarryMoss

@HarryMoss HarryMoss commented Oct 21, 2026 •

Copy link
Copy Markdown

Summary

Adds trace.uid to audit records emitted within a valid, sampled OpenTelemetry span so operators can find the corresponding trace. Dedicated supervisors with OTLP tracing enabled emit INFO spans for enforced network and HTTP refusals at the default log level. Companion records share one decision scope, including stopped-response diagnostics and the applicable terminal failure finding. Ordinary transport failures do not create enforced-denial spans.

This remains a draft pending the validation and maintainer decisions below.

Related Issue

Closes #2640

Changes

  • Adds optional correlation to all ten event builders, direct emission and gateway-routed emission. Explicit correlation takes precedence; openshell-ocsf has no OpenTelemetry dependency.
  • Extends Trace-profile applicability from official OCSF 1.8 HTTP/API classes to eight additional classes. The vendored catalog validates this OpenShell extension. Strict consumers may reject it; maintainer disposition remains pending. The operator guide and schema README name the affected classes.
  • Groups companion denials under one span and retains the HTTP policy decision as the primary event for forward evaluation-error spans, preserving available policy/class attributes. Exports ocsf.class_uid as an integer for numeric backend queries. Tests exported IDs, available parents, concurrent contexts and allowed/audit controls.
  • Groups stopped-response diagnostics with terminal findings. Full-relay regressions cover response failures before/after commitment, HEAD finalization, ordinary upstream resets/downstream body-write failures and allowed/fail-open controls. A separate helper test covers terminal failure without diagnostics.
  • Classifies FORWARD parse, target, GraphQL and JSON-RPC refusals, plus metadata 403/405 responses, as Denied/Blocked. L7 evaluation-error records use Denied/Blocked in enforce mode and Other/Allowed in audit mode; their message becomes L7 evaluation failed: ….
  • Removes trace object/profile from existing OCSF 1.1/1.3 projections. Documents sampled-context delivery limits, caller-propagated gateway hints and the shared-supervisor export limitation.
  • Integrates upstream snapshot c4688aa8715ea75d6c39fe468361dbbc16c691fa, preserving borrowed/shared event contexts and trace propagation through all ten builders.

Submitted revision

The upstream integration is 36cc028102d03f73a0c9e836f406fdc9ae7581fb, followed by the review corrections in 910509993e18659404e923a2aa33d772a4ae5655. Both new commits contain DCO sign-offs. The submitted head is 910509993e18659404e923a2aa33d772a4ae5655, with verified source tree e231ce833d5999a46d664121858cac22cdc526aa. The existing correlation and legacy-projection commits remain in branch history, and the original 3fae097a branch is preserved.

Testing

macOS arm64, Rust 1.95.0, locked dependencies and repository default features/profiles. Results are attributed to their executed source below.

  • Final network library suite on e231ce833d5999a46d664121858cac22cdc526aa: 1,561 passed, 0 failed, 2 ignored. The crate's tests/ integration suite was not freshly rerun.
  • OCSF/OTel: 270 passed, 2 ignored, executed on 939f725a8d9973578d0d13dea8b974b5836910c8; reused after verifying subsequent changes are confined to private network Rust source/tests. OCSF/OTel source and configuration are identical.
  • Final five-crate all-target Clippy with -D warnings and formatting passed. Changed-page Markdown lint, docs navigation and Fern passed on 47dde144cbe107b0d265968c87d93c72bca37a63; docs/configuration are unchanged. Fern reported zero errors and three existing warnings.
  • The old-order overlay reproduced the missing policy span attribute. Earlier response regressions reproduced three failures on preceding production code; final regressions pass.
  • Earlier results remain separately pinned to 61c776ab16b1f3886d67c0cd8a52e5f140a0f218: supervisor 193 passed, gateway/server 2,372 passed, 21 ignored. Their source is unchanged; these are not fresh reruns.

Fresh Linux compilation/tests, deployed default-level deny-to-Collector/Tempo validation with allowed/schema controls, NVIDIA CI approval/completion and reviewer reassessment remain pending for the submitted revision. Local loopback exporter tests do not establish a deployed pass. Kevin's independent review and rhuss's deployed check apply to 3fae097a5d4355eeff77746f3ea90e89228455e3 only.

Checklist

  • Relevant unit and integration coverage added/updated
  • Affected local tests, five-crate Clippy, formatting and docs checks pass
  • Operator documentation updated
  • Follow-up commits use Conventional Commit messages and DCO sign-offs, and are published
  • Maintainer disposition of the additional Trace-profile applicability
  • Fresh Linux/deployed validation completed for submitted revision
  • NVIDIA CI approval and required checks completed
  • Reviewer reassessment completed

Shared supervisor hosts currently initialize no OTLP exporters and provide no automatic sandbox trace correlation. Separate native-conformance/catalog work in #4288 and broader downgrade removal in #4284 are outside this PR.

Thanks to @kvnloo for the independent review and pinned compatibility evidence, @rhuss for review, schema/span-lifetime guidance and the earlier deployed check, and @zanetworker for the related native schema-conformance work in #4288. These acknowledgements do not imply approval of this revision.

Add optional trace.uid enrichment through a plain sampled-context extractor,
with explicit correlation taking precedence on all ten event builders.
Register the extractor at gateway and supervisor startup and keep an INFO
span active around network and HTTP deny emissions at the default log level.

Integrate the Authentication event and current network APIs while retaining
sanitization, policy decisions, credential handling and conditional DEBUG
parenting. Document per-decision traces and export/retention limits.

Signed-off-by: Harry Turner <harry.github@loopfeed.com>
Remove the top-level trace object and Trace profile from the existing
1.1/1.3 projection path, keeping this compatibility contract separate from
native trace correlation.

Test trace removal for both legacy targets and native OTLP body preservation,
including source-event immutability. Document the legacy trace contract
without redesigning downgrade behavior or claiming full schema conformance.

Signed-off-by: Harry Turner <harry.github@loopfeed.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 21, 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.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

All contributors have signed the DCO ✍️ ✅
Posted by the DCO Assistant Lite bot.

@HarryMoss

Copy link
Copy Markdown
Author

I have read the DCO document and I hereby sign the DCO.

@HarryMoss

Copy link
Copy Markdown
Author

recheck

Merge NVIDIA/OpenShell main at d223943.
Keep both resource_details and the Trace schemas in the vendored schema
README, correct its object count and preserve the refresh instructions.

Retain both published correlation commits and the tested source tree
247a27f.

Signed-off-by: Harry Turner <harry.github@loopfeed.com>
@HarryMoss

Copy link
Copy Markdown
Author

The README conflict is resolved, and the updated branch passes the local checks. Could a maintainer please vet and approve the CI workflows? Fresh Linux/Collector/Tempo validation remains pending.

@rhuss rhuss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cc-review Summary

@HarryMoss, please have your agent assess each of these comments and check it against the code before acting on it. Some may turn out not to apply, and I would rather you confirm or refute them than take them at face value.

This review comes from an agent-assisted run with several reviewers (cc-review: seven prompt reviewers plus Codex and CodeRabbit, then a skeptic/calibration pass). The findings and the scenarios in them come from the agents reading the code. Nothing was executed or reproduced, and I have not verified each scenario myself.

What Went Well

  • crates/openshell-otel/src/correlation.rs current_ocsf_trace_correlation only correlates when the span context is both valid and sampled.
  • crates/openshell-ocsf/src/trace.rs keeps openshell-ocsf free of OpenTelemetry through a OnceLock extractor, and enrich never overrides an explicit trace.
  • crates/openshell-ocsf/src/events/base_event.rs set_trace adds the trace profile only once.
  • crates/openshell-ocsf/src/format/downgrade.rs strips both the trace field and the profile for 1.1 and 1.3, with tests for each target.
  • crates/openshell-supervisor-network/src/telemetry/tests.rs exported checks real OTLP/gRPC exports instead of only the JSON shape.

Findings

Severity File Description Source
Important crates/openshell-supervisor-network/src/telemetry.rs:32 Paired deny event and finding get different trace IDs gator
Minor crates/openshell-server/src/cli.rs:669 Gateway trace.uid is caller-propagated but documented as trusted security
Minor crates/openshell-supervisor-network/src/l7/middleware.rs:466 Capacity-exhausted finding marked deny when allowed correctness
Minor crates/openshell-supervisor-network/src/l7/middleware.rs:487 Load shedding traced as policy deny goal-alignment
Minor crates/openshell-supervisor-network/src/l7/middleware.rs:1004 denied: predicates untested test-quality
Minor crates/openshell-supervisor-network/src/proxy.rs:1261 Transparent middleware deny not parented on connect span (see below) correctness
Minor crates/openshell-supervisor-network/src/proxy.rs:5677 Non-canonical rejection gets no deny span codex
Minor crates/openshell-supervisor-network/src/proxy.rs:5956 CONNECT/FORWARD deny-span wiring untested test-quality
Minor crates/openshell-supervisor-network/src/proxy.rs:6072 Audit-mode L7 failure traced as enforced deny codex
Minor crates/openshell-supervisor-network/src/proxy.rs:6660 parent: is a no-op for non-deny events architecture
Minor crates/openshell-supervisor-network/src/proxy/trace_tests.rs:80 Debug deny test lacks count and uid checks test-quality
Minor crates/openshell-supervisor-network/src/telemetry.rs:11 Local ocsf_emit! shadows the upstream macro architecture
Minor crates/openshell-supervisor-network/src/telemetry.rs:41 Deny classification split between event and flag architecture
Minor crates/openshell-supervisor-network/src/telemetry.rs:55 Deny-span policy/host attributes never asserted test-quality
Minor crates/openshell-supervisor-network/src/telemetry.rs:64 One exported root span per deny, no rate gate production
Minor crates/openshell-ocsf/src/builders/network.rs:121 .trace() setter on 10 builders used only by tests architecture
Minor crates/openshell-otel/src/lib.rs:49 Supervisor filter moved into shared public API architecture
Minor docs/observability/ocsf-json-export.mdx:281 Docs mention the internal builder API architecture

Outside the diff: crates/openshell-supervisor-network/src/proxy.rs:1261. The transparent-TCP fail-closed middleware deny calls emit_middleware_uninspectable without the live connect_span, unlike the sibling transparent denies, so at debug level this deny is not linked to the connect/authorize trace even though the docs say it should be. An optional parent parameter, or wrapping the call in connect_span.in_scope(...), would fix it.

Observation: the supervisor.egress.deny span and its openshell.policy.* keys live in telemetry.rs, separate from the supervisor.egress.* spans in proxy/egress.rs, and the attribute values come from string-keyed evidence lookups. Shared constants would keep the two in sync.

Review Details

  • Findings posted: 18
  • Findings reviewed and not posted: 0
  • Gate outcome: FAIL (one Important finding)
  • Participating reviewers: architecture, correctness, goal-alignment, production, security, test-quality, gator, codex (CodeRabbit ran with no findings)

Comment on lines +32 to +87
pub fn emit(event: OcsfEvent, parent: Option<&tracing::Span>, denied: bool) {
let (action, endpoint, policy) = match &event {
OcsfEvent::NetworkActivity(e) => {
(e.action, e.dst_endpoint.as_ref(), e.firewall_rule.as_ref())
}
OcsfEvent::HttpActivity(e) => (e.action, e.dst_endpoint.as_ref(), e.firewall_rule.as_ref()),
OcsfEvent::DetectionFinding(e) => (e.action, None, None),
_ => (None, None, None),
};
if !denied && action != Some(ActionId::Denied) {
openshell_ocsf::ocsf_emit!(event);
return;
}

// Finding evidence supplies decision fields, not a trace parent.
let evidence = |key: &str| match &event {
OcsfEvent::DetectionFinding(e) => e.evidences.as_ref().and_then(|items| {
items
.iter()
.find_map(|item| item.data.as_ref()?.get(key)?.as_str())
}),
_ => None,
};
let policy = policy
.map(|rule| rule.name.as_str())
.or_else(|| evidence("policy"));
let address = endpoint
.and_then(|endpoint| endpoint.domain.as_deref().or(endpoint.ip.as_deref()))
.or_else(|| evidence("host"));
let port = endpoint.and_then(|endpoint| endpoint.port);
let reason = event.base().status_detail.as_deref();
let parent = parent.cloned().unwrap_or_else(tracing::Span::current);
let span = tracing::info_span!(
parent: &parent,
"supervisor.egress.deny",
openshell.policy.decision = "deny",
openshell.policy.name = tracing::field::Empty,
openshell.policy.reason = tracing::field::Empty,
server.address = tracing::field::Empty,
server.port = tracing::field::Empty,
ocsf.class_uid = event.class_uid(),
);
if let Some(policy) = policy {
span.record("openshell.policy.name", policy);
}
if let Some(reason) = reason {
span.record("openshell.policy.reason", reason);
}
if let Some(address) = address {
span.record("server.address", address);
}
if let Some(port) = port {
span.record("server.port", i64::from(port));
}
span.in_scope(|| openshell_ocsf::ocsf_emit!(event));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: emit opens a new supervisor.egress.deny span for every OCSF event it handles. A denial is dual-emitted (domain event plus DetectionFinding), so the two records of one incident end up with different trace.uid values in separate root traces.

Why this matters: At the default level there is no active parent, so each emission starts its own trace. Affected sites include l7/rest.rs:1537 and :1551, proxy.rs:2948 together with l7/mod.rs:447 (the finding has no parent even at debug level), l7/relay.rs:290-291, l7/websocket.rs:1388-1389, the loop at l7/middleware.rs:1004 and the finding loops in l7/rest/http_response.rs. Pivoting from the finding's trace.uid then lands on a one-span trace that has nothing to do with the network event's trace, so the correlation is useless there. It also doubles span volume per denial.

Suggested fix: Open one deny span per decision and emit all events of that decision inside it, for example a telemetry::deny_scope(parent, &primary_event, || { ... }) helper, and let emit reuse an already active deny span instead of nesting a new one. A test that emits a network deny plus its finding and asserts one exported deny span and equal trace.uid values would keep this from regressing.

Source: gator agent (also flagged by: production agent, correctness agent)

Comment on lines +669 to +671
openshell_ocsf::register_trace_correlation_extractor(
openshell_otel::current_ocsf_trace_correlation,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: Registering current_ocsf_trace_correlation in the gateway stamps gateway audit events with the trace ID of the request span, and that span is parented on the caller's inbound traceparent (multiplex.rs:129-136) before authentication runs. On the gateway, trace.uid is therefore chosen by the caller, including on Authentication failure events.

Why this matters: Honoring inbound traceparent on the gateway is intentional (CI trace continuity from the CLI), so this is not a request to drop propagation. The problem is the contract: TraceCorrelation and correlation.rs describe the value as trusted, and #2640 says caller-controlled headers never go into trace. An unauthenticated client could then place its failed logons in another trace, or send flags=00 to keep them out of correlation. This scenario is derived from code reading and was not reproduced.

Suggested fix: Document that gateway trace.uid is caller-propagated (a correlation hint, not proof) and drop the "trusted" wording in trace.rs, base_event.rs and correlation.rs. Optionally, leave correlation off for events emitted before authentication succeeds.

Source: security agent (also flagged by: gator agent, goal-alignment agent)

.message("Persistent middleware session admission was refused at process capacity")
.build();
ocsf_emit!(event);
ocsf_emit!(denied: true, event);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: emit_middleware_session_capacity_exhausted always passes denied: true, but session_capacity_exhausted() returns allowed: !fail_closed. With an all-FailOpen chain the WebSocket upgrade is allowed and still gets a deny span and deny correlation.

Why this matters: Telemetry then reports a denial for traffic that went through. Enforcement is unaffected.

Suggested fix: Pass the decision in, e.g. emit_middleware_session_capacity_exhausted(ctx, !outcome.allowed) with ocsf_emit!(denied: denied, event).

Source: correctness agent


fn emit_middleware_admission_exhausted(ctx: &L7EvalContext) {
ocsf_emit!(middleware_admission_exhausted_event(ctx));
ocsf_emit!(denied: true, middleware_admission_exhausted_event(ctx));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: Load-shedding findings (admission_exhausted here, session_capacity_exhausted above) open INFO deny spans with openshell.policy.decision=deny, although no policy made that decision.

Why this matters: The request is refused, so a deny label is defensible. But it adds one exported span per shed request right when the process is saturated, and the "policy" attribute is misleading.

Suggested fix: Either drop denied: true for capacity refusals or document that they are deliberately traced as denies.

Source: goal-alignment agent

) {
for event in middleware_events(ctx, req, outcome) {
ocsf_emit!(event);
ocsf_emit!(denied: !outcome.allowed && matches!(event, openshell_ocsf::OcsfEvent::DetectionFinding(_)), event);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: The new denied: predicates for the middleware chain and WebSocket finding emissions (here and at lines 182, 267, 410, 430) have no tests for either branch.

Why this matters: Inverting any of these predicates keeps the suite green.

Suggested fix: Add a table-driven exported("warn", ...) test over emit_middleware_events and emit_websocket_preflight_events with an allowed and a denied outcome, asserting which events carry trace.uid and how many deny spans are exported.

Source: test-quality agent

}),
_ => None,
};
let policy = policy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: No test asserts the values of openshell.policy.name and server.address on the deny span, including the fallback to evidence("policy")/evidence("host").

Why this matters: assert_deny_resolves only checks that the decision and class attributes exist, so replacing the policy expression with None keeps the suite green.

Suggested fix: In the middleware and relay trace tests, assert openshell.policy.name == "middleware-policy" / "rest-policy" and server.address == "blocked.example".

Source: test-quality agent

let port = endpoint.and_then(|endpoint| endpoint.port);
let reason = event.base().status_detail.as_deref();
let parent = parent.cloned().unwrap_or_else(tracing::Span::current);
let span = tracing::info_span!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: Each denied event exports its own INFO root span at the default level, without rate limiting or aggregation.

Why this matters: This matches the stated design and scales like the audit records themselves, but a workload retrying a blocked host in a loop produces one trace per attempt and can fill the batch exporter queue.

Suggested fix: Consider a per-destination rate gate before creating the span, or link the existing sampler settings (OTEL_TRACES_SAMPLER) from the deny-trace section of the docs.

Source: production agent

impl<'a, EndpointState> NetworkActivityBuilder<'a, EndpointState> {
/// Set trace correlation explicitly instead of using the active span.
#[must_use]
pub fn trace(mut self, trace: crate::TraceCorrelation) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: All ten builders gain a public .trace(TraceCorrelation) setter with typestate plumbing, but only tests call it; production correlation goes through the registered extractor.

Why this matters: That is about 130 near-identical lines of public API, and TraceCorrelation.uid is a public String whose 32-hex invariant is only documented. The PR description lists the setters as intended, so treat this as a design question.

Suggested fix: Either let tests use BaseEventData::set_trace and drop the setters, or keep one validating constructor with a private field.

Source: architecture agent

/// Export supervisor spans at INFO by default, or DEBUG and TRACE when enabled.
/// Dependency spans remain at INFO.
#[must_use]
pub fn supervisor_span_filter(log_level: &str) -> tracing_subscriber::EnvFilter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: supervisor_span_filter is now public API of the shared openshell-otel crate, while openshell-supervisor/src/main.rs keeps an otlp_span_filter wrapper that only forwards to it. The PR description does not mention the new API.

Why this matters: A crate shared by the gateway and drivers now exports one binary's span policy, and the wrapper duplicates doc and test.

Suggested fix: Inline the call in main.rs and drop the wrapper, or keep the filter in the supervisor and give the tests a generic helper.

Source: architecture agent (also flagged by: goal-alignment agent)

Comment thread docs/observability/ocsf-json-export.mdx Outdated
with 32 lowercase hexadecimal characters. Search for it in your configured
Jaeger or Tempo backend to inspect the supervisor's handling of the deny.
Without an active sampled context, automatic correlation adds neither the
trace object nor its profile, including when OTLP export is disabled. Explicit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: "Explicit builder correlation takes precedence" describes an internal Rust builder API that operators cannot use.

Why this matters: The docs guidelines ask to leave out internals that do not affect users.

Suggested fix: Delete the sentence or move it to the openshell-ocsf README.

Source: architecture agent

Integrate upstream snapshot c4688aa while retaining optional trace correlation in the borrowed/shared-context builders.

Signed-off-by: Harry Turner <harry.github@loopfeed.com>
Group companion audit records and terminal response findings under one denial span. Keep ordinary transport errors out of enforced-denial spans, retain available HTTP policy context, and export the OCSF class as an integer. Clarify propagated context and the additional Trace-profile applicability, with regression coverage and operator documentation.

Signed-off-by: Harry Turner <harry.github@loopfeed.com>

Copy link
Copy Markdown
Author

Thanks for the review, @rhuss. The follow-up at 910509993e18659404e923a2aa33d772a4ae5655, following the upstream integration, groups companion deny records and terminal response failures under one decision span, corrects caller-propagated correlation wording, and keeps ordinary I/O failures out of enforced-denial spans. Forward evaluation-error spans retain the HTTP policy context, and ocsf.class_uid now exports as an integer.

The final source tree is e231ce833d5999a46d664121858cac22cdc526aa. Its network library suite passed 1,561 tests (2 ignored); five-crate all-target Clippy and formatting passed. OCSF/OTel and docs evidence is separately attributed to unchanged source in the updated PR description.

The docs now explicitly identify Trace applicability on the eight extra classes as an OpenShell extension beyond official OCSF 1.8 HTTP/API applicability. Please confirm whether that extension is acceptable. Fresh Linux and default-level Collector/Tempo validation with allowed/schema controls, NVIDIA CI approval/completion and reviewer reassessment remain pending. This remains Draft; earlier independent deployment results apply only to 3fae097a.

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(ocsf): add trace_id/span_id correlation fields to OCSF event builders

2 participants