From 577bf3b1139c1a40d5c641b4b3d1eaa974bcefca Mon Sep 17 00:00:00 2001
From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
Date: Wed, 5 Aug 2026 15:50:45 +0200
Subject: [PATCH 1/3] docs(actions): document RailOutcome-based rail actions
Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
---
.../actions/creating-actions.mdx | 40 +---
docs/configure-rails/actions/index.mdx | 7 +
.../configure-rails/actions/rail-outcomes.mdx | 196 ++++++++++++++++++
docs/index.yml | 3 +
4 files changed, 212 insertions(+), 34 deletions(-)
create mode 100644 docs/configure-rails/actions/rail-outcomes.mdx
diff --git a/docs/configure-rails/actions/creating-actions.mdx b/docs/configure-rails/actions/creating-actions.mdx
index 3e78a07bd4..331b9afe5d 100644
--- a/docs/configure-rails/actions/creating-actions.mdx
+++ b/docs/configure-rails/actions/creating-actions.mdx
@@ -31,7 +31,6 @@ async def my_custom_action():
| `name` | `str` | Custom name for the action | Function name |
| `is_system_action` | `bool` | Always run locally, bypassing the actions server | `False` |
| `execute_async` | `bool` | Don't block event processing while the action runs (Colang 2.x only) | `False` |
-| `output_mapping` | `Callable[[Any], bool]` | Function to interpret the action result for blocking decisions | `default_output_mapping` |
### Custom Action Name
@@ -87,42 +86,13 @@ async def call_external_api(endpoint: str):
return response.json()
```
-### Output Mapping
+### Rail Decisions
-The `output_mapping` parameter controls how the action's return value is interpreted to determine if output should be blocked. It accepts a callable that takes the return value and returns `True` if the output is **not safe** (should be blocked).
+The `@action` decorator does not interpret an action's return value as a safety decision. Ordinary custom actions can return strings, booleans, numbers, dictionaries, or other Python values for a Colang flow to consume explicitly.
-When no `output_mapping` is provided, the default behavior is:
-- **Boolean results**: `True` means allowed, `False` means blocked
-- **Numeric results**: Values below `0.5` are blocked
-- **Other types**: Allowed by default
+When the action itself makes a rail decision, return a [`RailOutcome`](/configure-guardrails/actions/rail-outcomes). It carries an explicit allow, block, or transform decision without relying on implicit boolean or numeric conventions.
-```python
-@action(output_mapping=lambda value: value)
-async def check_hallucination(context: Optional[dict] = None):
- """Return True if hallucination detected (blocked), False if safe."""
- return detect_hallucination(context.get("bot_message", ""))
-```
-
-```python
-@action(is_system_action=True, output_mapping=lambda value: not value)
-async def check_output_safety(context: Optional[dict] = None):
- """Return True if safe (allowed), mapped to not-blocked."""
- return is_safe(context.get("bot_message", ""))
-```
-
-You can also define a custom mapping function for more complex logic:
-
-```python
-def my_custom_mapping(result):
- if isinstance(result, dict):
- return result.get("score", 1.0) < 0.7
- return False
-
-@action(output_mapping=my_custom_mapping)
-async def score_safety(context: Optional[dict] = None):
- """Return a dict with a safety score."""
- return {"score": compute_score(context.get("bot_message", ""))}
-```
+If you previously used the removed `output_mapping` decorator parameter, follow the [migration guide](/configure-guardrails/actions/rail-outcomes#migrate-from-output-mapping).
## Function Parameters
@@ -174,6 +144,8 @@ async def search_documents(
Actions can return various types:
+Manifest-backed rail actions are the exception. They must return a [`RailOutcome`](/configure-guardrails/actions/rail-outcomes).
+
### Simple Return
```python
diff --git a/docs/configure-rails/actions/index.mdx b/docs/configure-rails/actions/index.mdx
index 730b4b7bd1..cd7e11a137 100644
--- a/docs/configure-rails/actions/index.mdx
+++ b/docs/configure-rails/actions/index.mdx
@@ -72,6 +72,13 @@ Register custom actions via actions.py, LLMRails.register_action(), or config.py
How To
+
+
+Return engine-neutral allow, block, and transform decisions from rail actions, including migration from `output_mapping`.
+
+Reference
+
+
## File Organization
diff --git a/docs/configure-rails/actions/rail-outcomes.mdx b/docs/configure-rails/actions/rail-outcomes.mdx
new file mode 100644
index 0000000000..c96e5bf8e0
--- /dev/null
+++ b/docs/configure-rails/actions/rail-outcomes.mdx
@@ -0,0 +1,196 @@
+---
+title: "Rail Outcomes"
+sidebar-title: "Rail Outcomes"
+description: "Return engine-neutral allow, block, and transform decisions from rail actions."
+keywords: ["RailOutcome", "rail actions", "rail decisions", "transform guardrails"]
+content:
+ type: "reference"
+---
+
+Rail actions return a `RailOutcome` to express an allow, block, or transform decision. This contract separates a rail's decision from the way a runtime presents or enforces that decision.
+
+Actions declared by a rail manifest must return `RailOutcome`. Ordinary custom actions can return other Python values when a Colang flow consumes those values explicitly, but the runtime does not infer a rail decision from a boolean, number, tuple, or dictionary.
+
+
+
+The `output_mapping` parameter and its default boolean and numeric mappings have been removed from `@action`. Passing `output_mapping` now raises `TypeError`. Migrate rail decisions to `RailOutcome`; do not rely on implicit return-value interpretation.
+
+
+
+## Decisions
+
+Each outcome contains exactly one decision.
+
+| Decision | Meaning |
+| --- | --- |
+| `allow` | Continue processing without changing the checked content. |
+| `block` | Stop processing the checked content. The runtime decides how to present the block. |
+| `transform` | Replace one or more supported conversation values before processing continues. |
+
+Import the outcome and transform target types from the actions package:
+
+```python
+from nemoguardrails.actions.rail_outcome import RailOutcome, TransformTarget
+```
+
+### Allow content
+
+```python
+return RailOutcome.allow(
+ reason="No policy category matched.",
+ metadata={"categories": []},
+)
+```
+
+### Block content
+
+```python
+return RailOutcome.block(
+ reason="The content matched a restricted category.",
+ metadata={"categories": ["restricted"]},
+)
+```
+
+A block outcome does not contain a refusal message, exception type, bot intent, or localized text. The runtime or Colang flow owns those presentation choices.
+
+### Transform content
+
+Use a transform outcome when the rail rewrites checked content. A transform must include at least one rewrite and cannot repeat a target.
+
+```python
+return RailOutcome.transform(
+ [(TransformTarget.RELEVANT_CHUNKS, sanitized_chunks)],
+ reason="Sensitive values were removed.",
+ metadata={"redaction_count": redaction_count},
+)
+```
+
+The supported transform targets are:
+
+- `TransformTarget.USER_MESSAGE`
+- `TransformTarget.BOT_MESSAGE`
+- `TransformTarget.RELEVANT_CHUNKS`
+
+Transform outcomes apply to non-streaming processing. Streaming output paths do not apply the rewrite.
+
+## Evidence fields
+
+Use `reason` for a neutral, human-readable explanation of the decision. Use `metadata` for structured evidence such as categories, scores, detections, or provider response details.
+
+Do not make `metadata` load-bearing for the decision. Consumers should use `decision`, `is_blocked`, or `is_transform` to determine the outcome. Treat metadata as potentially observable data and avoid storing secrets, credentials, or unnecessary user content.
+
+## Consume an outcome in a flow
+
+The action decides whether content is allowed, blocked, or transformed. The flow decides how to handle that result.
+
+```colang
+$result = await DetectCustomPolicyAction(text=$user_message)
+
+if $result.is_blocked
+ bot refuse to respond
+ abort
+```
+
+For a transform, read the replacement by its target name:
+
+```colang
+$result = await SanitizeCustomChunksAction(text=$relevant_chunks)
+
+if $result.is_transform
+ $relevant_chunks = $result.transform_text["relevant_chunks"]
+```
+
+The following convenience properties are available:
+
+| Property | Value |
+| --- | --- |
+| `is_blocked` | `True` only for a block outcome. |
+| `is_transform` | `True` only for a transform outcome. |
+| `transform_text` | A mapping from transform target names to replacement text. |
+
+## Migrate from `output_mapping`
+
+Replace the mapping function with an explicit decision at the action's return site. This makes the action's meaning visible to every runtime and avoids conventions such as whether `True` means safe or blocked.
+
+### Safe boolean results
+
+Previously, an action could return whether content was safe and negate that result through `output_mapping`:
+
+```python
+@action(output_mapping=lambda result: not result)
+async def check_output_safety(text: str) -> bool:
+ return is_safe(text)
+```
+
+Return the decision directly:
+
+```python
+from nemoguardrails.actions import action
+from nemoguardrails.actions.rail_outcome import RailOutcome
+
+@action()
+async def check_output_safety(text: str) -> RailOutcome:
+ if is_safe(text):
+ return RailOutcome.allow()
+ return RailOutcome.block(reason="The output did not pass the safety policy.")
+```
+
+### Unsafe boolean results
+
+For a detector where `True` means unsafe, return `block` when the detector matches:
+
+```python
+@action()
+async def check_hallucination(text: str) -> RailOutcome:
+ if detect_hallucination(text):
+ return RailOutcome.block(reason="The output failed the hallucination check.")
+ return RailOutcome.allow()
+```
+
+### Numeric thresholds and structured results
+
+Apply thresholds in the action and preserve useful evidence in metadata:
+
+```python
+@action()
+async def score_output_safety(text: str) -> RailOutcome:
+ score = compute_safety_score(text)
+ metadata = {"score": score, "threshold": 0.7}
+ if score < 0.7:
+ return RailOutcome.block(metadata=metadata)
+ return RailOutcome.allow(metadata=metadata)
+```
+
+The migration follows these mappings:
+
+| Previous convention | `RailOutcome` replacement |
+| --- | --- |
+| Safe boolean: `True` allows, `False` blocks | Return `allow()` for `True`; otherwise return `block()`. |
+| Unsafe boolean: `True` blocks, `False` allows | Return `block()` for `True`; otherwise return `allow()`. |
+| Numeric threshold | Compare the score in the action and return the chosen decision. |
+| Dictionary or tuple plus custom mapping | Read the relevant fields in the action and put non-sensitive evidence in `metadata`. |
+
+Update the consuming flow to inspect the outcome rather than the original scalar value:
+
+```colang
+$result = await CheckOutputSafetyAction(text=$bot_message)
+
+if $result.is_blocked
+ bot refuse to respond
+ abort
+```
+
+If the action is not a rail decision, keep its ordinary return type and branch on that value explicitly in the flow. `RailOutcome` is not required for general-purpose actions.
+
+## Validation rules
+
+`RailOutcome` validates its state when you construct it:
+
+- `reason` must be a string or `None`.
+- `metadata` must be a mapping with string keys.
+- Transform outcomes must contain one or more transforms.
+- Allow and block outcomes cannot contain transforms.
+- Each transform target can appear only once in an outcome.
+- Transform replacement values must be strings.
+
+Use the `allow`, `block`, and `transform` class methods instead of constructing decisions directly. These methods make the intended outcome clear at the action's return site.
diff --git a/docs/index.yml b/docs/index.yml
index 618d592e9e..24a65d9cea 100644
--- a/docs/index.yml
+++ b/docs/index.yml
@@ -219,6 +219,9 @@ navigation:
- page: Registering Actions
path: configure-rails/actions/registering-actions.mdx
slug: registering-actions
+ - page: Rail Outcomes
+ path: configure-rails/actions/rail-outcomes.mdx
+ slug: rail-outcomes
- section: Custom Initialization
path: configure-rails/custom-initialization/index.mdx
slug: custom-initialization
From 2e40996e76d076b44fbfe87a154a7cd465f7ca08 Mon Sep 17 00:00:00 2001
From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
Date: Wed, 5 Aug 2026 15:53:18 +0200
Subject: [PATCH 2/3] docs(rails): document rail manifests and action-backed
surfaces
Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
---
.../configure-rails/actions/rail-outcomes.mdx | 2 +-
docs/index.yml | 3 +
docs/reference/rail-manifests.mdx | 253 ++++++++++++++++++
3 files changed, 257 insertions(+), 1 deletion(-)
create mode 100644 docs/reference/rail-manifests.mdx
diff --git a/docs/configure-rails/actions/rail-outcomes.mdx b/docs/configure-rails/actions/rail-outcomes.mdx
index c96e5bf8e0..2308a389f3 100644
--- a/docs/configure-rails/actions/rail-outcomes.mdx
+++ b/docs/configure-rails/actions/rail-outcomes.mdx
@@ -9,7 +9,7 @@ content:
Rail actions return a `RailOutcome` to express an allow, block, or transform decision. This contract separates a rail's decision from the way a runtime presents or enforces that decision.
-Actions declared by a rail manifest must return `RailOutcome`. Ordinary custom actions can return other Python values when a Colang flow consumes those values explicitly, but the runtime does not infer a rail decision from a boolean, number, tuple, or dictionary.
+Actions declared by a [rail manifest](/reference/rail-manifests) must return `RailOutcome`. Ordinary custom actions can return other Python values when a Colang flow consumes those values explicitly, but the runtime does not infer a rail decision from a boolean, number, tuple, or dictionary.
diff --git a/docs/index.yml b/docs/index.yml
index 24a65d9cea..a1e41b7fe2 100644
--- a/docs/index.yml
+++ b/docs/index.yml
@@ -583,6 +583,9 @@ navigation:
- page: Engine Feature Support
path: reference/engine-feature-support.mdx
slug: engine-feature-support
+ - page: Rail Manifests
+ path: reference/rail-manifests.mdx
+ slug: rail-manifests
- folder: _static/python-sdk-reference/guardrails-python-sdk
title: Python SDK Reference
collapsed: true
diff --git a/docs/reference/rail-manifests.mdx b/docs/reference/rail-manifests.mdx
new file mode 100644
index 0000000000..d6f0bcc9e5
--- /dev/null
+++ b/docs/reference/rail-manifests.mdx
@@ -0,0 +1,253 @@
+---
+title: "Rail Manifest Reference"
+sidebar-title: "Rail Manifests"
+description: "Declare built-in rail metadata, configuration, actions, flows, surfaces, requirements, and privacy behavior."
+keywords: ["rail manifest", "RailManifest", "rail catalog", "rail surfaces", "action manifest"]
+content:
+ type: "reference"
+---
+
+A rail manifest is the versioned, declarative contract for a built-in rail under `nemoguardrails/library`. It identifies the rail, describes its configuration and dependencies, declares its actions and Colang flows, and exposes action-backed input, output, or retrieval surfaces.
+
+The manifest keeps discovery separate from execution. Import references remain strings until the runtime needs the corresponding configuration factory or action. This lets the catalog inspect a rail without eagerly importing its optional integration dependencies.
+
+## File layout
+
+A manifest-backed library rail uses the following files:
+
+```text
+nemoguardrails/library/example_rail/
+├── __init__.py
+├── actions.py
+├── flows.co
+├── flows.v1.co
+├── rail.py
+└── rail_config.py
+```
+
+Only `rail.py` and `actions.py` are required. Add the other files when the rail provides Colang flows or typed configuration.
+
+The `rail.py` module must:
+
+- Import manifest types from `nemoguardrails.manifests`.
+- Define one module-level `RAIL` value containing a `RailManifest`.
+- Avoid importing the action implementation, configuration implementation, or optional provider packages.
+
+The built-in catalog discovers `rail.py` modules under `nemoguardrails/library`. It does not discover arbitrary application or third-party package paths.
+
+## Minimal manifest
+
+The following manifest declares one action and one input surface:
+
+```python
+from nemoguardrails.manifests import (
+ ActionRef,
+ Binding,
+ RailActions,
+ RailDirection,
+ RailManifest,
+ RailMetadata,
+ RailPrivacy,
+ RailSpec,
+ RailSurface,
+)
+
+CHECK_CUSTOM_POLICY = ActionRef(
+ name="check_custom_policy",
+ target="nemoguardrails.library.example_rail.actions:check_custom_policy",
+)
+
+RAIL = RailManifest(
+ name="example_rail",
+ metadata=RailMetadata(
+ display_name="Example Rail",
+ description="Checks user messages against an example policy.",
+ categories=("input",),
+ capabilities=("allow", "block"),
+ tags=("built-in",),
+ docs_url="docs/configure-rails/guardrail-catalog/community/example-rail.mdx",
+ ),
+ spec=RailSpec(
+ actions=RailActions(refs=(CHECK_CUSTOM_POLICY,)),
+ surfaces=(
+ RailSurface(
+ name="example check input",
+ direction=RailDirection.INPUT,
+ action=CHECK_CUSTOM_POLICY,
+ bindings=(Binding.context("text", "user_message"),),
+ ),
+ ),
+ privacy=RailPrivacy(),
+ ),
+)
+```
+
+## Top-level fields
+
+| Field | Purpose |
+| --- | --- |
+| `manifest_version` | Selects the manifest schema. The current and default value is `1`. |
+| `name` | Provides the unique, stable identifier for the rail. |
+| `metadata` | Describes the rail for documentation, discovery, and filtering. It does not change runtime behavior. |
+| `spec` | Declares executable configuration, flows, actions, surfaces, requirements, and privacy behavior. |
+
+`RailMetadata` supports display text, categories, capabilities, tags, documentation URL, lifecycle, owner, and version. Categories and capabilities use the manifest taxonomies. Use `tags` for labels that do not belong to those taxonomies.
+
+## Actions
+
+Declare each rail action with an `ActionRef`:
+
+```python
+CHECK_CUSTOM_POLICY = ActionRef(
+ name="check_custom_policy",
+ target="nemoguardrails.library.example_rail.actions:check_custom_policy",
+)
+```
+
+The `name` is the registered action name. It must agree with the action decorator. The `target` uses the `module:attribute` import-reference format.
+
+List every action reference in `RailActions`, including actions used by surfaces:
+
+```python
+actions=RailActions(refs=(CHECK_CUSTOM_POLICY,))
+```
+
+Manifest actions are registered lazily. The action module and its optional dependencies are imported when the action is resolved, not when the catalog first reads the manifest.
+
+Every action declared by a rail manifest must return a [`RailOutcome`](/configure-guardrails/actions/rail-outcomes). Actions decide whether to allow, block, or transform content. Colang flows and other runtimes decide how to present and enforce that decision.
+
+## Colang flows
+
+Use `RailFlows` when the rail includes Colang implementations:
+
+```python
+from nemoguardrails.manifests import RailFlows
+
+flows=RailFlows(
+ files=("flows.co",),
+ v1_files=("flows.v1.co",),
+ flow_names=("example check input",),
+)
+```
+
+`files` lists Colang 2.x files, `v1_files` lists Colang 1.0 files, and `flow_names` declares the public flow names owned by the rail. The default file names are `flows.co` and `flows.v1.co`.
+
+Keep both dialect implementations behaviorally equivalent when the rail supports both. The flows should consume `RailOutcome` properties and own presentation behavior such as refusal intents and stopping the flow.
+
+## Action-backed surfaces
+
+A `RailSurface` describes how to invoke a declared action in one pipeline direction. It is independent of a Colang implementation.
+
+| Field | Purpose |
+| --- | --- |
+| `name` | Identifies the configured rail surface. |
+| `direction` | Selects `input`, `output`, or `retrieval`. |
+| `action` | References an action declared in the same manifest. |
+| `bindings` | Maps runtime values and configured parameters to action parameters. |
+| `transform_target` | Declares the conversation value a transform surface rewrites. |
+
+Use binding constructors to identify where each action argument comes from:
+
+| Binding | Source |
+| --- | --- |
+| `Binding.context("text", "user_message")` | A runtime context value. |
+| `Binding.surface_param("model_name", "model")` | A parameter supplied with the configured surface. |
+| `Binding.literal("source", "input")` | A constant declared by the manifest. |
+
+Set `required=False` on a context or surface-parameter binding only when the action can operate without that value. A surface cannot bind the same action parameter more than once.
+
+When an action can return a transform outcome, set `transform_target` to the value it rewrites:
+
+```python
+from nemoguardrails.manifests import TransformTarget
+
+RailSurface(
+ name="example sanitize retrieval",
+ direction=RailDirection.RETRIEVAL,
+ action=SANITIZE_CUSTOM_TEXT,
+ bindings=(Binding.context("text", "relevant_chunks"),),
+ transform_target=TransformTarget.RELEVANT_CHUNKS,
+)
+```
+
+## Typed configuration
+
+Use `RailConfigSchema` to project a rail-specific field under `rails.config`:
+
+```python
+from nemoguardrails.manifests import ConfigSpecRef, RailConfigSchema
+
+config_schema=RailConfigSchema(
+ key="example_rail",
+ spec=ConfigSpecRef(
+ target="nemoguardrails.library.example_rail.rail_config:build_config_spec"
+ ),
+)
+```
+
+The referenced factory must return a `RailConfigSpec`. Keep the factory and its model types in `rail_config.py` so reading the manifest does not import the implementation eagerly.
+
+## Requirements and privacy
+
+Declare install and runtime requirements instead of leaving them implicit:
+
+```python
+from nemoguardrails.manifests import (
+ EnvVar,
+ RailPrivacy,
+ RailRequirements,
+ ServiceRequirement,
+)
+
+requirements=RailRequirements(
+ extras=("example",),
+ env_vars=(EnvVar(name="EXAMPLE_API_KEY", required=True),),
+ services=(ServiceRequirement(name="Example API", required=True),),
+)
+
+privacy=RailPrivacy(
+ sends_user_text=True,
+ remote_services=("Example API",),
+ data_retention="See the provider data policy.",
+)
+```
+
+`RailRequirements` can declare package extras, environment variables, services, model resources, and optional dependencies. `RailPrivacy` records whether the rail sends user messages, bot messages, or retrieved chunks to remote services, and can describe provider retention behavior.
+
+These declarations must match the action's actual behavior. Do not include credentials or secret values in the manifest.
+
+## Catalog validation
+
+The built-in `RailCatalog` validates the combined manifest set. Catalog construction fails when it finds:
+
+- Duplicate manifest names.
+- Duplicate configuration keys.
+- Duplicate public flow names.
+- Duplicate action names.
+- Duplicate surface names in the same direction.
+- A surface that references an action not declared by its manifest.
+
+You can inspect the built-in catalog through the public manifest API:
+
+```python
+from nemoguardrails.manifests import (
+ RailDirection,
+ all_rail_manifests,
+ default_rail_catalog,
+)
+
+manifests = all_rail_manifests()
+input_surfaces = default_rail_catalog().surfaces(direction=RailDirection.INPUT)
+```
+
+## Author checklist
+
+- Define a lightweight `RAIL` value in `rail.py`.
+- Use stable, globally unique manifest, action, flow, configuration, and surface names.
+- Keep import targets declarative and point them to the owning implementation modules.
+- Return `RailOutcome` from every declared action.
+- Keep Colang 1.0 and 2.x flows equivalent when both are present.
+- Bind every action parameter that the runtime must supply.
+- Declare transform targets, dependencies, external services, environment variables, and privacy behavior accurately.
+- Add unit tests for the action and manifest contract and recorded tests for LLM or HTTP boundaries when applicable.
+- Add or update the rail's catalog documentation page.
From 0e4a8e6af7a6a71e81c52280e8990d55489eb660 Mon Sep 17 00:00:00 2001
From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
Date: Wed, 5 Aug 2026 16:03:26 +0200
Subject: [PATCH 3/3] docs(http): document canonical outbound HTTP clients
Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
---
.../actions/creating-actions.mdx | 49 ++--
docs/configure-rails/actions/index.mdx | 7 +
.../configure-rails/actions/outbound-http.mdx | 223 ++++++++++++++++++
.../actions/registering-actions.mdx | 14 +-
.../custom-initialization/init-function.mdx | 36 +--
docs/index.yml | 3 +
docs/observability/metrics/reference.mdx | 41 +++-
docs/observability/tracing/span-reference.mdx | 33 ++-
8 files changed, 357 insertions(+), 49 deletions(-)
create mode 100644 docs/configure-rails/actions/outbound-http.mdx
diff --git a/docs/configure-rails/actions/creating-actions.mdx b/docs/configure-rails/actions/creating-actions.mdx
index 331b9afe5d..03794cc035 100644
--- a/docs/configure-rails/actions/creating-actions.mdx
+++ b/docs/configure-rails/actions/creating-actions.mdx
@@ -79,10 +79,15 @@ This flag is only supported in the Colang 2.x runtime. In the Colang 1.0 runtime
```python
+from nemoguardrails.http import HTTPClient, http_call
+
@action(execute_async=True)
-async def call_external_api(endpoint: str):
+async def call_external_api(
+ endpoint: str,
+ http_client: HTTPClient | None = None,
+):
"""Call an external API without blocking event processing."""
- response = await http_client.get(endpoint)
+ response = await http_call(http_client, "GET", endpoint)
return response.json()
```
@@ -181,18 +186,19 @@ async def is_safe_content(context: Optional[dict] = None):
Handle errors gracefully within actions:
```python
+from nemoguardrails.http import HTTPClient, HTTPTimeoutError, http_call
+
@action()
-async def fetch_data(url: str):
+async def fetch_data(
+ url: str,
+ http_client: HTTPClient | None = None,
+):
"""Fetch data with error handling."""
try:
- response = await http_client.get(url)
- response.raise_for_status()
+ response = await http_call(http_client, "GET", url)
return response.json()
- except Exception as e:
- # Log the error
- print(f"Error fetching data: {e}")
- # Return a safe default or raise
- return None
+ except HTTPTimeoutError as error:
+ raise RuntimeError("External data service timed out") from error
```
## Example Actions
@@ -239,18 +245,22 @@ async def filter_sensitive_data(context: Optional[dict] = None):
### External API Action
```python
-import aiohttp
+from nemoguardrails.http import HTTPClient, http_call
@action(execute_async=True)
-async def query_knowledge_base(query: str, top_k: int = 5):
+async def query_knowledge_base(
+ query: str,
+ top_k: int = 5,
+ http_client: HTTPClient | None = None,
+):
"""Query an external knowledge base API."""
- async with aiohttp.ClientSession() as session:
- async with session.post(
- "https://api.example.com/search",
- json={"query": query, "limit": top_k}
- ) as response:
- data = await response.json()
- return data.get("results", [])
+ response = await http_call(
+ http_client,
+ "POST",
+ "https://api.example.com/search",
+ json={"query": query, "limit": top_k},
+ )
+ return response.json().get("results", [])
```
## Related Topics
@@ -258,3 +268,4 @@ async def query_knowledge_base(query: str, top_k: int = 5):
- [Built-in Actions](built-in-actions) - Default actions in the library
- [Action Parameters](action-parameters) - Special parameters provided automatically
- [Registering Actions](registering-actions) - Different ways to register actions
+- [Outbound HTTP](outbound-http) - Send external requests through the canonical client boundary
diff --git a/docs/configure-rails/actions/index.mdx b/docs/configure-rails/actions/index.mdx
index cd7e11a137..d57cc5f72d 100644
--- a/docs/configure-rails/actions/index.mdx
+++ b/docs/configure-rails/actions/index.mdx
@@ -79,6 +79,13 @@ Return engine-neutral allow, block, and transform decisions from rail actions, i
Reference
+
+
+Use the canonical client boundary for lifecycle, retries, observability, and deterministic tests.
+
+How To
+
+
## File Organization
diff --git a/docs/configure-rails/actions/outbound-http.mdx b/docs/configure-rails/actions/outbound-http.mdx
new file mode 100644
index 0000000000..3e48a32ae8
--- /dev/null
+++ b/docs/configure-rails/actions/outbound-http.mdx
@@ -0,0 +1,223 @@
+---
+title: "Outbound HTTP in Actions"
+sidebar-title: "Outbound HTTP"
+description: "Use the canonical asynchronous HTTP boundary for action requests, retries, telemetry, lifecycle management, and deterministic tests."
+keywords: ["HTTPClient", "http_call", "action HTTP", "HTTP retries", "HTTP telemetry"]
+content:
+ type: "how_to"
+---
+
+Use the `nemoguardrails.http` boundary for outbound HTTP requests from actions and library rails. It provides transport-neutral request, response, error, retry, and instrumentation contracts while keeping client ownership explicit.
+
+Do not construct `aiohttp`, `httpx`, `requests`, or `urllib3` clients inside an action. Direct transports bypass shared ownership, retry, testing, and observability policy.
+
+## Send a request from an action
+
+Accept an optional `HTTPClient` and call `http_call`:
+
+```python
+from nemoguardrails.actions import action
+from nemoguardrails.http import HTTPClient, http_call
+
+@action()
+async def query_policy_service(
+ text: str,
+ http_client: HTTPClient | None = None,
+):
+ response = await http_call(
+ http_client,
+ "POST",
+ "https://policy.example.com/v1/check",
+ json={"text": text},
+ timeout=10.0,
+ )
+ return response.json()
+```
+
+`http_call` raises `HTTPStatusError` for responses with status code 400 or greater by default. Pass `raise_for_status=False` only when the integration must inspect an error response and apply provider-specific behavior.
+
+## Client ownership
+
+The client argument determines ownership for each call:
+
+| Client value | Owner | Behavior |
+| --- | --- | --- |
+| Injected `HTTPClient` | Caller | `http_call` borrows the client and leaves it open. |
+| `None` | `http_call` | The helper creates a client for the call and closes it after the response body is materialized. |
+
+The `None` fallback is safe and deterministic. Inject a shared client for applications that make repeated requests and benefit from connection-pool reuse.
+
+Create and close a shared client at the same application-lifecycle boundary:
+
+```python
+from nemoguardrails import LLMRails, RailsConfig
+from nemoguardrails.http import create_http_client
+
+async def run():
+ http_client = create_http_client(timeout=10.0)
+ config = RailsConfig.from_path("config")
+ app = LLMRails(config)
+ app.register_action_param("http_client", http_client)
+
+ try:
+ return await app.generate_async(
+ messages=[{"role": "user", "content": "Hello"}],
+ )
+ finally:
+ await http_client.close()
+```
+
+The synchronous `config.py` initialization hook has no asynchronous teardown hook. Do not create a long-lived HTTP client there unless another application component owns and closes it.
+
+## Request and response contract
+
+`HTTPClient.request` and `http_call` support:
+
+- HTTP method and absolute URL.
+- Headers and query parameters.
+- A JSON body or raw string or byte content.
+- A per-request total timeout.
+
+They return a materialized `HTTPResponse`. Its body bytes remain available after a call-scoped client closes.
+
+```python
+response.status_code
+response.headers
+response.content
+response.text
+response.json()
+response.is_success
+```
+
+`HTTPResponse.json()` raises `HTTPResponseDecodeError` for invalid JSON. `HTTPResponse.raise_for_status()` and `http_call` raise `HTTPStatusError` for status codes of 400 or greater.
+
+The canonical error hierarchy is:
+
+- `HTTPClientError`
+- `HTTPConnectionError`
+- `HTTPTimeoutError`
+- `HTTPStatusError`
+- `HTTPResponseDecodeError`
+
+Catch the narrowest error that the integration can handle without changing its intended fail-open or fail-closed behavior. Do not log response bodies, request bodies, credentials, or unsanitized exception details.
+
+## Configure the pooled transport
+
+`create_http_client` creates a closable HTTPX-backed client behind the neutral protocol. The default client:
+
+- Uses a 30-second total timeout.
+- Verifies TLS certificates.
+- Does not follow redirects.
+- Pools up to 100 connections, including up to 20 keep-alive connections.
+
+Override these policies explicitly when an integration requires different behavior:
+
+```python
+import httpx
+
+from nemoguardrails.http import HTTPTLSConfig, create_http_client
+
+client = create_http_client(
+ timeout=10.0,
+ limits=httpx.Limits(max_connections=40, max_keepalive_connections=10),
+ follow_redirects=False,
+ tls=HTTPTLSConfig(ca_bundle="/path/to/ca-bundle.pem"),
+)
+```
+
+`HTTPTLSConfig` also supports a client certificate and key for mutual TLS. Configure both together. Keep certificate verification enabled in production.
+
+## Add bounded retries
+
+Requests are not retried unless the client has a `RetryPolicy`. `max_attempts` includes the initial request.
+
+```python
+from nemoguardrails.http import RetryPolicy, create_http_client
+
+policy = RetryPolicy(
+ max_attempts=3,
+ initial_delay=0.25,
+ max_delay=2.0,
+)
+client = create_http_client(retry_policy=policy)
+```
+
+The default policy retries eligible connection and timeout failures and the status codes `408`, `409`, `429`, `500`, `502`, `503`, and `504`. It uses bounded exponential backoff with jitter and accepts an in-policy `Retry-After` value.
+
+The safe default method set excludes `POST`. Add `POST` only when the provider documents the operation as retry-safe or the request uses a supported idempotency mechanism:
+
+```python
+policy = RetryPolicy(
+ max_attempts=3,
+ retryable_methods=frozenset({"POST"}),
+)
+```
+
+Keep retry policy close to the integration that owns the provider semantics. Do not add a broad global POST retry policy.
+
+## Add privacy-safe instrumentation
+
+Wrap a shared client with `instrument_http_client` to enable tracing, metrics, or both:
+
+```python
+from opentelemetry import trace
+
+from nemoguardrails.http import (
+ RetryPolicy,
+ create_http_client,
+ instrument_http_client,
+)
+
+base_client = create_http_client(retry_policy=RetryPolicy())
+http_client = instrument_http_client(
+ base_client,
+ tracer=trace.get_tracer("guardrails-app"),
+ metrics_enabled=True,
+)
+```
+
+Wrap the retrying client, as shown above, to record one span and one duration observation for the logical request rather than one per retry attempt.
+
+Tracing emits a `CLIENT` span named `HTTP {METHOD}`. Metrics emit the `http.client.request.duration` histogram. Telemetry can include:
+
+- Method, URL scheme, server address, and port.
+- A sanitized URL without credentials, query parameters, or fragments.
+- Raw request-body size when `content` is used.
+- Response status and body size.
+- Retry count and error type.
+
+Instrumentation does not record header values, query values, JSON bodies, raw body content, credentials, response content, or exception messages. Telemetry failures do not change the request result.
+
+Instrumentation is explicit at this boundary. Creating an HTTP client without `instrument_http_client` does not enable HTTP spans or metrics automatically. The component that creates the instrumented client must also close it.
+
+See [Span Reference](/observability/tracing/span-reference#outbound-http-client-spans) and [Metric Reference](/observability/metrics/reference#http-client-metrics) for the emitted names and attributes.
+
+## Test without network access
+
+Use `RecordingHTTPClient` to queue responses and inspect the exact provider request:
+
+```python
+import pytest
+
+from nemoguardrails.http import HTTPResponse
+from nemoguardrails.testing import RecordingHTTPClient
+
+@pytest.mark.asyncio
+async def test_policy_request_contract():
+ client = RecordingHTTPClient(
+ [HTTPResponse(status_code=200, content=b'{"allowed": true}')]
+ )
+
+ result = await query_policy_service("hello", http_client=client)
+
+ assert result == {"allowed": True}
+ assert len(client.requests) == 1
+ request = client.requests[0]
+ assert request.method == "POST"
+ assert request.url == "https://policy.example.com/v1/check"
+ assert request.json == {"text": "hello"}
+```
+
+Queue transport errors and non-success responses to verify retry, timeout, decoding, and fail-open or fail-closed behavior. Unit tests must not call a live provider.
+
+For configuration-level testing patterns, see [Testing Your Guardrails Configuration](/configure-guardrails/custom-initialization/testing-your-config).
diff --git a/docs/configure-rails/actions/registering-actions.mdx b/docs/configure-rails/actions/registering-actions.mdx
index 878d6a2e98..f11261b86f 100644
--- a/docs/configure-rails/actions/registering-actions.mdx
+++ b/docs/configure-rails/actions/registering-actions.mdx
@@ -256,23 +256,22 @@ Provide shared resources to actions:
# config/config.py
def init(app: LLMRails):
# Create shared resources
- http_client = aiohttp.ClientSession()
cache = RedisCache()
# Register as action parameters
- app.register_action_param("http_client", http_client)
app.register_action_param("cache", cache)
```
```python
# config/actions.py
from nemoguardrails.actions import action
+from nemoguardrails.http import HTTPClient, http_call
@action()
async def fetch_with_cache(
url: str,
- http_client=None, # Injected automatically
- cache=None # Injected automatically
+ cache=None,
+ http_client: HTTPClient | None = None,
):
# Check cache first
cached = await cache.get(url)
@@ -280,13 +279,15 @@ async def fetch_with_cache(
return cached
# Fetch and cache
- response = await http_client.get(url)
- data = await response.json()
+ response = await http_call(http_client, "GET", url)
+ data = response.json()
await cache.set(url, data)
return data
```
+An injected HTTP client remains caller-owned and must be closed by the application that created it. See [Outbound HTTP in Actions](/configure-guardrails/actions/outbound-http#client-ownership) for the shared-client lifecycle pattern.
+
## Best Practices
### 1. Use Descriptive Names
@@ -345,3 +346,4 @@ async def search_knowledge_base(
- [Creating Custom Actions](creating-actions) - Create your own actions
- [Built-in Actions](built-in-actions) - Default actions in the library
- [Action Parameters](action-parameters) - Special parameters for actions
+- [Outbound HTTP](outbound-http) - Inject and manage the canonical HTTP client
diff --git a/docs/configure-rails/custom-initialization/init-function.mdx b/docs/configure-rails/custom-initialization/init-function.mdx
index 59a481fc2d..d5464f9d04 100644
--- a/docs/configure-rails/custom-initialization/init-function.mdx
+++ b/docs/configure-rails/custom-initialization/init-function.mdx
@@ -117,22 +117,28 @@ def init(app: LLMRails):
app.register_action_param("db_conn", conn)
```
-## Example: API Client Initialization
+## HTTP Client Lifecycle
-```python
-import os
-import httpx
-from nemoguardrails import LLMRails
-
-def init(app: LLMRails):
- # Get API key from custom_data in config.yml
- api_key = os.environ.get("API_KEY") or app.config.custom_data.get("api_key")
+The synchronous `init` function has no asynchronous teardown hook. Do not create a long-lived HTTP client in `init` unless another application component owns and closes it.
- # Create HTTP client with authentication
- client = httpx.AsyncClient(
- base_url="https://api.example.com",
- headers={"Authorization": f"Bearer {api_key}"}
- )
+Create a shared canonical client at the surrounding application-lifecycle boundary, register it as an action parameter, and close it at shutdown:
- app.register_action_param("http_client", client)
+```python
+from nemoguardrails import LLMRails, RailsConfig
+from nemoguardrails.http import create_http_client
+
+async def run():
+ http_client = create_http_client(timeout=10.0)
+ config = RailsConfig.from_path("config")
+ app = LLMRails(config)
+ app.register_action_param("http_client", http_client)
+
+ try:
+ return await app.generate_async(
+ messages=[{"role": "user", "content": "Hello"}],
+ )
+ finally:
+ await http_client.close()
```
+
+Actions that accept `http_client: HTTPClient | None = None` can use a shared injected client or let `http_call` create and close a call-scoped fallback. See [Outbound HTTP in Actions](/configure-guardrails/actions/outbound-http).
diff --git a/docs/index.yml b/docs/index.yml
index a1e41b7fe2..8d2bd39bd8 100644
--- a/docs/index.yml
+++ b/docs/index.yml
@@ -222,6 +222,9 @@ navigation:
- page: Rail Outcomes
path: configure-rails/actions/rail-outcomes.mdx
slug: rail-outcomes
+ - page: Outbound HTTP
+ path: configure-rails/actions/outbound-http.mdx
+ slug: outbound-http
- section: Custom Initialization
path: configure-rails/custom-initialization/index.mdx
slug: custom-initialization
diff --git a/docs/observability/metrics/reference.mdx b/docs/observability/metrics/reference.mdx
index 90bb7e6fd5..1a09ed4be4 100644
--- a/docs/observability/metrics/reference.mdx
+++ b/docs/observability/metrics/reference.mdx
@@ -3,18 +3,21 @@
# SPDX-License-Identifier: Apache-2.0
title: "Metric Reference"
sidebar-title: "Metric Reference"
-description: "Reference every metric IORails emits, with instrument types, units, labels, and emission semantics."
+description: "Reference IORails and canonical outbound HTTP metrics, including instrument types, units, labels, and emission semantics."
content:
type: "reference"
---
-This page lists every metric the IORails engine emits when `metrics.enabled: true` and a `MeterProvider` is configured.
+This page lists the metrics emitted by the IORails engine and the instrumented canonical outbound HTTP client.
-Metrics fall into two families:
+Metrics fall into three families:
- **Request-level metrics** (`guardrails.*`) describe IORails request flow: volume, errors, blocks, latency, and saturation of the streaming and non-streaming admission paths.
- **LLM client-side metrics** (`gen_ai.client.*`) describe downstream LLM calls IORails issues.
These follow the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/) and use the bucket boundaries recommended by that spec.
+- **HTTP client-side metrics** (`http.client.*`) describe outbound requests sent through an explicitly instrumented canonical HTTP client.
+
+IORails request and LLM metrics require `metrics.enabled: true` and a configured `MeterProvider`. HTTP client metrics require a configured `MeterProvider` and `instrument_http_client(..., metrics_enabled=True)`.
## Request-Level Metrics
@@ -77,6 +80,28 @@ A `QueueFull` rejection on the non-streaming path increments **both**:
This is intentional: dashboards built around either signal alone still reflect the rejection.
+## HTTP Client Metrics
+
+The canonical outbound HTTP instrumentation records one observation for each logical request, including all retry attempts performed by a wrapped retrying client.
+
+| Metric | Instrument | Unit | Labels | Description |
+| --- | --- | --- | --- | --- |
+| `http.client.request.duration` | Histogram | `s` | `http.request.method`, `server.address`, `server.port`, optionally `http.response.status_code` and `error.type` | Wall-clock duration of an outbound HTTP request. |
+
+The method is normalized to uppercase. The server port is the explicit URL port or the default port for `http` and `https`. A response contributes `http.response.status_code`; status codes of 400 or greater also contribute `error.type` as the status string. A raised exception contributes its class name as `error.type` and has no response-status label.
+
+The metric does not include URL paths, query parameters, headers, bodies, credentials, or exception messages. URLs without a host or a recognized port do not produce an observation.
+
+### Bucket Boundaries: `http.client.request.duration`
+
+The histogram uses the OpenTelemetry HTTP client recommendation in seconds:
+
+```text
+[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0]
+```
+
+Use [`instrument_http_client`](/configure-guardrails/actions/outbound-http#add-privacy-safe-instrumentation) with `metrics_enabled=True` to enable this metric. `create_http_client` does not enable it automatically.
+
## LLM Client-Side Metrics
These metrics are recorded once per downstream LLM call, not once per IORails request, and follow the OpenTelemetry GenAI semantic conventions.
@@ -127,21 +152,27 @@ When usage is absent, no observation is recorded; "no observation" is deliberate
| Label | Used On | Values | Notes |
| --- | --- | --- | --- |
-| `error.type` | `guardrails.requests.errors`, `gen_ai.client.operation.duration` (on error) | Exception class name | For example `QueueFull`, `TimeoutError`, `ValueError`. |
+| `error.type` | Request, LLM, and HTTP error metrics | Exception class name or HTTP error status | For example `QueueFull`, `TimeoutError`, `HTTPConnectionError`, or `503`. |
| `rail.type` | `guardrails.requests.blocked` | `input`, `output` | Identifies whether an input or output rail blocked the request. |
| `gen_ai.operation.name` | All `gen_ai.client.*` | For example `chat`, `completion`, `embedding` | OpenTelemetry GenAI operation name. |
| `gen_ai.provider.name` | All `gen_ai.client.*` | For example `openai`, `anthropic` | OpenTelemetry GenAI provider name. |
| `gen_ai.request.model` | All `gen_ai.client.*` | For example `gpt-4o-mini` | The model name passed in the request. |
| `gen_ai.token.type` | `gen_ai.client.token.usage` | `input`, `output` | Required label per spec. |
+| `http.request.method` | `http.client.request.duration` | Uppercase HTTP method | For example `GET` or `POST`. |
+| `server.address` | `http.client.request.duration` | Destination host | Excludes credentials and port. |
+| `server.port` | `http.client.request.duration` | Destination port | Uses `80` or `443` when omitted from an HTTP or HTTPS URL. |
+| `http.response.status_code` | `http.client.request.duration` | HTTP status code | Present only when a response is received. |
## Public API Stability
The metric names listed on this page are part of the library's public API, so dashboards and alerts can reference them.
The library tests assert on the raw strings for this reason.
-Bucket boundaries follow the OpenTelemetry GenAI spec and can change if the spec changes.
+Bucket boundaries follow the relevant OpenTelemetry HTTP or GenAI specification and can change if those specifications change.
## Related Resources
- [Enable Guardrails Metrics](/observability/metrics/enable-metrics) — Minimal SDK setup with console output.
- [OpenTelemetry Metrics Integration](/observability/metrics/opentelemetry-integration) — Production exporters: OTLP, Prometheus.
+- [Outbound HTTP in Actions](/configure-guardrails/actions/outbound-http): Canonical client ownership, retries, instrumentation, and testing.
+- [OpenTelemetry HTTP metrics specification](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/): Upstream HTTP client metric conventions.
- [OpenTelemetry GenAI metrics specification](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/) — Upstream semantic conventions.
diff --git a/docs/observability/tracing/span-reference.mdx b/docs/observability/tracing/span-reference.mdx
index ceec72ef1b..af0359beb3 100644
--- a/docs/observability/tracing/span-reference.mdx
+++ b/docs/observability/tracing/span-reference.mdx
@@ -3,13 +3,36 @@
# SPDX-License-Identifier: Apache-2.0
title: "Span Reference"
sidebar-title: "Span Reference"
-description: "Every span and attribute the LLMRails and IORails engines emit when tracing is enabled, including the token-level GenAI attributes on the LLM span."
+description: "Reference the LLMRails, IORails, and canonical outbound HTTP spans and attributes emitted when tracing is enabled."
content:
type: "reference"
---
The NeMo Guardrails library emits OpenTelemetry spans, allowing you to trace individual requests.
-This reference documents the spans and attributes each engine produces. It covers the default LLMRails engine first, then the opt-in IORails engine.
+This reference documents shared outbound HTTP client spans and the spans each engine produces.
+
+## Outbound HTTP Client Spans
+
+The canonical outbound HTTP boundary can emit a `CLIENT` span for each logical request. This instrumentation is engine-neutral and is enabled by wrapping a client with `instrument_http_client` and supplying a tracer. It is not enabled by `create_http_client` alone.
+
+The span is named `HTTP {METHOD}`, such as `HTTP GET` or `HTTP POST`. When a request runs under an active OpenTelemetry span, the HTTP span uses that span as its parent.
+
+| Attribute | Type | When set | Description |
+| --- | --- | --- | --- |
+| `http.request.method` | string | Always | Uppercase HTTP method. |
+| `url.full` | string | Always | URL with credentials, query parameters, and fragments removed. |
+| `url.scheme` | string | URL has a scheme | URL scheme, such as `https`. |
+| `server.address` | string | URL has a host | Destination host. |
+| `server.port` | int | URL contains an explicit port | Destination port. |
+| `http.request.body.size` | int | Raw `content` is supplied | Encoded raw request-body size. JSON bodies are not measured. |
+| `http.response.status_code` | int | Response received | HTTP response status code. |
+| `http.response.body.size` | int | Response received | Materialized response-body size. |
+| `http.request.resend_count` | int | Request retried | Number of completed retries. |
+| `error.type` | string | Error response or exception | Status code for an error response or exception class name for a raised error. |
+
+Raised errors add an `exception` event containing only `exception.type`. The instrumentation does not record header values, query values, JSON bodies, raw body content, credentials, response content, or exception messages.
+
+Wrap the retrying client rather than its underlying transport to produce one span for the logical request and include its retry count. See [Outbound HTTP in Actions](/configure-guardrails/actions/outbound-http#add-privacy-safe-instrumentation) for configuration and lifecycle examples.
## LLMRails
@@ -285,7 +308,7 @@ When content capture is enabled, the LLM span also records the prompt and comple
| ---------- | ------ | -------- | --------------------------------- |
| `api.name` | string | Always | The name of the API being called. |
-These endpoints are plain HTTP services rather than GenAI operations, so the span uses `api.name` instead of the `gen_ai.*` attributes. HTTP transport attributes can be added later without conflict.
+These endpoints are plain HTTP services rather than GenAI operations, so the span uses `api.name` instead of the `gen_ai.*` attributes. When the action uses an instrumented canonical HTTP client, the nested `HTTP {METHOD}` span carries the transport attributes described in [Outbound HTTP Client Spans](#outbound-http-client-spans).
## Token-Level Attributes
@@ -369,5 +392,7 @@ Pin your `opentelemetry-sdk` version and review release notes before upgrading.
- [Quick Start](/observability/tracing/quick-start): Minimal tracing setup with the OpenTelemetry SDK.
- [OpenTelemetry](/observability/tracing/opentelemetry-integration): Production exporters and ecosystem compatibility.
-- [Metric Reference](/observability/metrics/reference): The metrics IORails emits, including the `gen_ai.client.token.usage` histogram.
+- [Metric Reference](/observability/metrics/reference): IORails and canonical outbound HTTP metrics.
+- [Outbound HTTP in Actions](/configure-guardrails/actions/outbound-http): Canonical request, ownership, retry, and instrumentation patterns.
+- [OpenTelemetry HTTP spans specification](https://opentelemetry.io/docs/specs/semconv/http/http-spans/): Upstream HTTP client span conventions.
- [OpenTelemetry GenAI spans specification](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/): Upstream semantic conventions for span names and attributes.