# Hydrogen Custom Report Transports This document explains how report transports work in Hydrogen, how the built-in transports behave, and what you need to do to add your own transport implementation. ## Where Transports Fit in the Hydrogen Pipeline Hydrogen builds a report after all security modules finish. The reporting flow in `reporting/service.py` is: 1. Build an `AuditReport`. 2. Render it into a `RenderedReport`. 3. Publish the rendered report through a transport. The relevant method is: ```python def publish(self, report: AuditReport, report_config: ResolvedReportOutput) -> str: rendered_report = self.render(report, report_config) transport = self._transport_registry.get(report_config.transport.type) return transport.publish(rendered_report, report_config.transport) ``` This means transports operate on already-rendered content. They do not receive raw findings directly. ## Core Transport Contract The base transport contract lives in `core/base.py`. ```python class BaseTransport(ABC): transport_type: str config_model: type[BaseModel] = EmptyPluginConfig @abstractmethod def publish(self, rendered_report: RenderedReport, transport: ResolvedPluginConfig) -> str: ... ``` Every transport must define: - `transport_type` — the name used in `config.yaml`. - `config_model` — optional Pydantic model for transport-specific configuration. - `publish(...)` — the delivery implementation. The `transport` parameter is a `ResolvedPluginConfig`: ```python @dataclass(frozen=True) class ResolvedPluginConfig: type: str config: BaseModel ``` - `transport.type` — the transport type string. - `transport.config` — the validated config model instance. The return value of `publish()` is a string. The built-in transports return values such as the output path or an HTTP status string. Hydrogen currently does not use that return value elsewhere, but your transport still needs to return one. ## Typed Transport Pattern Hydrogen provides `TypedTransport` as a convenient base class for transports with their own config model: ```python class TypedTransport(BaseTransport, Generic[TReportTransport], ABC): config_model: type[TReportTransport] def publish(self, rendered_report: RenderedReport, transport: ResolvedPluginConfig) -> str: if not isinstance(transport.config, self.config_model): raise TypeError( f"Transport '{self.transport_type}' expected {self.config_model.__name__}, " f"got {type(transport.config).__name__}" ) return self.publish_typed(rendered_report, cast(TReportTransport, transport.config)) @abstractmethod def publish_typed(self, rendered_report: RenderedReport, transport: TReportTransport) -> str: ... ``` This is the recommended pattern when your transport has its own configuration model. Benefits: - Runtime type checking for the config object. - Cleaner transport code — `publish_typed()` receives the typed config directly. - No manual casting inside `publish()`. ## How Hydrogen Discovers Transports Transport discovery is implemented in `reporting/bootstrap.py` and `core/plugin_types.py`. Hydrogen discovers transports from two sources: 1. **Package scan** — Python packages listed in `plugin_packages.transports` (default: `["reporting.transports"]`). 2. **Entry points** — `hydrogen.transports` entry points registered by installed packages. ```python transport_descriptors, transport_errors = discover_plugins( PluginGroupConfig( package_names=plugin_packages.transports, entry_point_group="hydrogen.transports", plugin_kind="transport", ), BaseTransport, ) ``` The discovery process: - Imports the package and all its submodules via `pkgutil.walk_packages`. - Inspects each module for non-abstract subclasses of `BaseTransport`. - Filters out classes that are only imported (not defined) in that module. - Instantiates each discovered class with no constructor arguments. - Registers them in `TransportRegistry` by `transport_type`. Important consequences for custom transports: - Your class must be a non-abstract subclass of `BaseTransport`. - The class must be defined in the module itself, not only imported from another module. - The class constructor must work with no arguments. - Registration is based on `transport_type` — names must be unique. ## Built-In Transport: `file` Implementation: `reporting/transports/file.py` Config model: `FileTransportConfig` ```python class FileTransportConfig(BaseModel): path: str = Field() append_extension: bool = Field(True) ``` Configuration: ```yaml reports: outputs: - renderer: type: json transport: type: file path: reports/latest append_extension: true ``` Behavior: - `path` is converted to `Path`. - If `append_extension` is `true` and the destination has no suffix, Hydrogen appends the renderer's file extension. - Parent directories are created automatically. - The report is written as UTF-8 text. ```python destination = Path(transport.path) if transport.append_extension and not destination.suffix: destination = destination.with_suffix(rendered_report.file_extension) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(rendered_report.content, encoding="utf-8") ``` Practical example: - `path: reports/audit` with the JSON renderer becomes `reports/audit.json`. - `path: reports/audit.txt` stays `reports/audit.txt`. ## Built-In Transport: `webhook` Implementation: `reporting/transports/webhook.py` Config model: `WebhookTransportConfig` ```python class WebhookTransportConfig(BaseModel): url: AnyHttpUrl = Field() method: WebhookMethod = Field(WebhookMethod.POST) headers: dict[str, str] = Field(default_factory=dict) timeout_seconds: float = Field(10.0, gt=0) payload_mode: WebhookPayloadMode = Field(WebhookPayloadMode.RENDERED) ``` Configuration: ```yaml reports: outputs: - renderer: type: json transport: type: webhook url: https://example.internal/ingest method: POST headers: Authorization: Bearer token timeout_seconds: 10 payload_mode: rendered ``` Supported fields: - `url` — validated as HTTP or HTTPS URL by Pydantic. - `method` — `POST`, `PUT`, or `PATCH`. - `headers` — arbitrary string-to-string HTTP headers. - `timeout_seconds` — positive float. - `payload_mode` — `rendered` or `envelope`. ### `payload_mode: rendered` Hydrogen sends the renderer output as the raw HTTP body. Header behavior: - Existing custom headers are preserved. - `Content-Type` defaults to the renderer media type if you did not set it explicitly. For the built-in JSON renderer, that means `Content-Type: application/json` unless overridden. ### `payload_mode: envelope` Hydrogen sends a JSON object with report metadata and content: ```json { "format": "json", "content_type": "application/json", "file_extension": ".json", "content": "...rendered report..." } ``` Header behavior: - `Content-Type` defaults to `application/json`. ## Adding a Custom Transport To add a custom transport, you need two things: 1. A transport config model. 2. A transport class implementing `BaseTransport` or `TypedTransport`. ### Via Package (In-Repository) Create a file under `reporting/transports/`: `reporting/transports/stdout.py`: ```python from pydantic import BaseModel, Field from core.base import TypedTransport from reporting.models import RenderedReport class StdoutTransportConfig(BaseModel): prefix: str = Field("[Hydrogen]") class StdoutTransport(TypedTransport[StdoutTransportConfig]): transport_type = "stdout" config_model = StdoutTransportConfig def publish_typed(self, rendered_report: RenderedReport, transport: StdoutTransportConfig) -> str: print(f"{transport.prefix}\n{rendered_report.content}") return "stdout" ``` Use it in `config.yaml`: ```yaml reports: outputs: - renderer: type: json transport: type: stdout prefix: "[Hydrogen Report]" ``` ### Via Entry Points (Third-Party Package) If you distribute your transport as a pip-installable package, register it via entry points: ```toml [project.entry-points."hydrogen.transports"] stdout = "my_package.transports:StdoutTransport" ``` The entry point value can be: - A class reference (Hydrogen instantiates it). - A pre-built instance. Hydrogen validates that the result is a non-abstract subclass or instance of `BaseTransport`. ### Via `plugin_packages` Configuration You can add additional packages for transport discovery: ```yaml plugin_packages: transports: - reporting.transports - mycompany.transport_extras ``` Hydrogen scans all listed packages and their submodules for `BaseTransport` subclasses. ## Configuration Schema for Custom Transports Unlike the old architecture (which required a Pydantic discriminated union), transports no longer need to be added to a central type union. The new `PluginConfigRef` model uses `extra="allow"`: ```python class PluginConfigRef(BaseModel): model_config = ConfigDict(extra="allow") type: str = Field(min_length=1) def payload(self) -> dict[str, Any]: return dict(self.model_extra or {}) ``` This means: - The `type` field selects the transport by name. - Any additional YAML fields are captured as the "payload". - Hydrogen validates the payload against your transport's `config_model` at runtime. There is **no central transport union** to modify. As long as your transport is discovered and registered, and its `config_model` matches the provided payload, it will work. ## The `__init__.py` Export You may export your custom transport from `reporting/transports/__init__.py`, but Hydrogen discovery does not depend on that export. Why: - Hydrogen walks the package tree and imports submodules directly. - Discovery inspects those imported submodules, not just the `__init__.py` exports. So this file is optional for plugin loading: ```python from reporting.transports.stdout import StdoutTransport ``` It is still useful if you want a cleaner package API for developers. ## Transport Name Collisions `TransportRegistry.register()` stores transports in a dictionary by `transport_type`: ```python self._transports[transport.transport_type] = transport ``` This means: - Names must be unique. - If two transports declare the same `transport_type`, `register()` raises a `ValueError`. In `bootstrap.py`, collisions are caught and converted to `PluginLoadError`: ```python try: transport_registry.register(descriptor.plugin) except ValueError as exc: errors.append(PluginLoadError(plugin_kind="transport", source=descriptor.source, message=str(exc))) ``` Choose stable, explicit names for custom Hydrogen transports. ## Limits of the Current Hydrogen Transport Design Before designing a transport extension, be aware of the current limits: - Transports are instantiated with no constructor arguments. - There is no dependency injection container. - There is no built-in retry, queueing, or backoff abstraction. - The transport type string must match `transport_type` exactly. These limits do not make custom transports impossible. They just mean the extension point is code-first rather than fully pluggable from YAML alone. ## Troubleshooting Custom Transports If a transport does not work in Hydrogen, check these points first: 1. The class subclasses `BaseTransport` or `TypedTransport`. 2. The class is defined inside a module under one of the configured transport packages. 3. The class is not abstract. 4. The class can be instantiated with no arguments. 5. `transport_type` matches the YAML `transport.type` value. 6. The transport's `config_model` validates the YAML payload correctly. 7. The renderer selected by `renderer.type` exists and runs successfully. 8. If using extra YAML fields, they are validated by the transport's `config_model` (not by `PluginConfigRef`). ## Summary In Hydrogen, a transport is the final delivery mechanism for a rendered report. The implementation is straightforward: - A transport class discoverable from configured packages or entry points. - An optional config model validated at runtime via `PluginConfigRef`. If you remember that discovery and configuration are separate concerns, extending Hydrogen transports becomes predictable and low-risk. Unlike the old architecture, there is no central transport union to modify — any discovered transport with a valid `config_model` is immediately usable.