Runtime Events and Scopes

View as Markdown

The native runtime handle lets plugin middleware participate in Relay’s event hierarchy without linking the host runtime crate. The example’s runtime group emits a configured mark, opens and closes a child scope, and optionally creates an isolated scope stack.

Preserve Stack Ownership

A scope stack determines visible scope-local middleware, event parentage, and cleanup. Typed async middleware captures the callback’s stack and restores it around every future and stream poll. The example inspects the current scope, then uses the isolated stack’s with_current helper to bind it for a synchronous closure and restore the previous thread binding afterward. Child tasks created with tokio::spawn do not automatically inherit Relay scope context, so a plugin that deliberately moves work into a new task or thread must use the explicit capture and binding helpers.

An isolated stack begins with its own root and is appropriate only when the emitted work should not be a child of the managed application call. The example creates the stack, binds it, pushes and pops its child scope, restores the previous binding, and drops the isolated stack through the typed guards. Those guards perform the same cleanup if the closure returns an error.

1pub(crate) fn emit_configured_runtime_events(
2 runtime: &PluginRuntime,
3 tag: &str,
4 config: &RuntimeConfig,
5) -> nemo_relay_plugin::Result<()> {
6 let _current_scope = runtime.current_scope()?;
7 if config.emit_marks {
8 runtime.emit_mark(
9 "example.native.request.seen",
10 Some(&json!({ "tag": tag })),
11 None,
12 )?;
13 }
14
15 let mut scope = runtime.scope(
16 "example.native.request",
17 ScopeType::Custom,
18 Some(&json!({ "tag": tag })),
19 None,
20 None,
21 )?;
22 scope.close(Some(&json!({ "done": true })), None)?;
23
24 if config.emit_isolated_scope {
25 let isolated = runtime.create_scope_stack()?;
26 isolated.with_current(|| {
27 if config.emit_marks {
28 runtime.emit_mark(
29 "example.native.isolated.mark",
30 Some(&json!({ "tag": tag })),
31 None,
32 )?;
33 }
34 let mut child = runtime.scope(
35 "example.native.isolated.scope",
36 ScopeType::Custom,
37 None,
38 Some(&json!({ "visibility": "isolated" })),
39 None,
40 )?;
41 child.close(Some(&json!({ "done": true })), None)
42 })?;
43 }
44 Ok(())
45}

runtime.scope returns an owning guard. Calling close supplies the end-event output; dropping an unclosed guard still follows the SDK cleanup path. with_current restores the prior stack after either Ok or Err, and dropping isolated releases the stack.

Emit Typed Telemetry and Read Diagnostics

Use emit_mark_with_options when a mark needs a data schema or log severity. Use emit_metric to create a validated Relay metric mark rather than constructing the reserved metric schema yourself. The following code adds metadata to a policy mark and then reads one runtime diagnostic:

1let schema = DataSchema::builder()
2 .name("example.policy.decision")
3 .version("1")
4 .build();
5runtime.emit_mark_with_options(
6 "example.native.policy_decision",
7 Some(&json!({ "allowed": true })),
8 None,
9 Some(&schema),
10 Some(LogSeverity::Info),
11)?;
12
13let diagnostics = runtime.runtime_diagnostics()?;
14if let Some(diagnostic) = diagnostics.get("otel.metric_mark_invalid") {
15 eprintln!("{} occurred {} times", diagnostic.message, diagnostic.count);
16}

The diagnostic snapshot is bounded and ordered by code. It is a host-level view, so it does not identify the plugin that recorded a diagnostic. These operations require the native host ABI v4 extensions.

Control Global Runtime Registrations

Native host ABI v4 adds activation-owned registration discovery and conditional middleware guardrail control. PluginRuntime::list_runtime_registrations returns structured global identities. A background plugin task can call register_conditional_middleware_guardrail, retain the returned opaque handle, and call deregister_conditional_middleware_guardrail when the target should be eligible for future snapshots again.

The host evaluates a constant reason callback locally. It does not enter the native library while resolving the target. The plugin can remove only gates owned by its activation, and Relay removes remaining gates during activation teardown. Use PluginContext::register_conditional_middleware_guardrail for a gate that must exist as part of the initial registration transaction.

The following registration uses the example’s validated, disabled-by-default registration_control group:

1if config.registration_control.enabled {
2 context.register_conditional_middleware_guardrail(
3 "documentation_registration_control",
4 &config.registration_control.kinds.iter().copied().collect(),
5 &config.registration_control.registration_name,
6 &config.registration_control.reason,
7 )?;
8}

The native SDK installs the constant reason in the host. Relay does not enter the native library while it evaluates future snapshots. Refer to Conditional Middleware Guardrails for the complete discovery, ordering, snapshot, and cleanup contract.

Configure the SDK Executor

NativePlugin::executor_config supplies the plugin-wide default. The SDK default and the example default are two worker threads. executor_config_for_component can derive a component override; the standard implementation recognizes a positive executor.worker_threads integer. Because the example schema rejects unknown values, it declares the executor object even though the object is owned by the SDK contract.

More workers help only when measured async I/O leaves callbacks queued. They do not make CPU-bound or blocking callbacks safe. A host with many active native components has one executor per component, so an unnecessarily large value multiplies thread use.

1impl NativePlugin for ExampleNativePlugin {
2 fn executor_config(&self) -> NativeExecutorConfig {
3 NativeExecutorConfig { worker_threads: 2 }
4 }
5
6 fn validate(&self, config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
7 let mut diagnostics = config::validate(config);
8 if let Err(message) = self.executor_config_for_component(config) {
9 diagnostics.push(ConfigDiagnostic {
10 level: DiagnosticLevel::Error,
11 code: "examples.rust_native_policy.invalid_executor".into(),
12 component: Some("examples.rust_native_policy".into()),
13 field: Some("executor.worker_threads".into()),
14 message,
15 });
16 }
17 diagnostics
18 }
19}

With no component override, this implementation creates two executor workers. With executor.worker_threads = 4, the standard component resolver creates four. Zero, negative, fractional, and nonnumeric values become validation errors before the SDK creates an executor.

Verify Runtime Cleanup

Use the following procedure to verify parentage, restoration, executor settings, and failure cleanup:

  1. Activate runtime.emit_marks = true and emit_isolated_scope = true with two executor workers.
  2. Run a managed call inside a named scope. Confirm the plugin mark and ordinary child scope use that call as their parent.
  3. Confirm the isolated scope uses its new root and has no accidental parent from the caller.
  4. In a host integration test, make one runtime operation fail after a push. Confirm the scope guard closes the scope, with_current restores the former stack, and dropping the isolated stack releases its host handle.
  5. Run unrelated work on the same runtime thread and confirm its current stack is unchanged. Then clear the plugin and wait for active callbacks to finish before unloading the library.

Success means event parentage is intentional, no scope or isolated stack leaks on failure, later work regains its original context, and unload begins only after callback ownership has ended.