Skip to content

Exporter plugins

native_observability_otel defines a plugin type for pushing trace data to an external destination. Plugins are discovered under Plugin/NativeObservabilityExporter in any enabled module, through Drupal\native_observability_otel\Attribute\NativeObservabilityExporter.

The attribute

namespace Drupal\native_observability_otel\Attribute;

#[\Attribute(\Attribute::TARGET_CLASS)]
final class NativeObservabilityExporter extends Plugin {
  public function __construct(
    public readonly string $id,
    public readonly TranslatableMarkup $label,
    public readonly ?TranslatableMarkup $description = NULL,
  ) {}
}

id is the plugin ID other code uses to fetch the exporter (ExporterRegistry::get($id)). label and description are shown in the OpenTelemetry settings form and anywhere else the exporter list is rendered.

ExporterInterface

namespace Drupal\native_observability_otel\Plugin\NativeObservabilityExporter;

interface ExporterInterface extends PluginInspectionInterface {
  public function getLabel(): string;
  public function getDescription(): string;
  public function isAvailable(): bool;
  public function export(array $payload): bool;
}

isAvailable() gates whether the exporter is offered and invoked at all; a typical implementation checks that the exporter is enabled and correctly configured before returning TRUE. export() receives the normalized trace payload built by the calling subscriber and returns TRUE on success, FALSE on failure. Nothing in the interface constrains export() to be synchronous or to make a network call: the shipped prometheus and null_exporter plugins both return TRUE immediately without doing anything.

ExporterBase (abstract class ExporterBase extends PluginBase implements ExporterInterface) provides default getLabel() and getDescription() implementations that read from the plugin definition, and a default isAvailable() that returns TRUE. Concrete plugins extend it and only need to implement export(), overriding isAvailable() when the exporter has its own enable/configuration gate.

Shipped plugins

Plugin ID Class Behavior
null_exporter NullExporter No-op. export() returns TRUE. Exists to validate exporter discovery.
prometheus PrometheusExporter No-op. export() returns TRUE. Prometheus is pull-based; the real scrape path is PrometheusMetricsController in native_observability_export, entirely outside this plugin system. This plugin only advertises Prometheus in the exporter list.
opentelemetry OpenTelemetryExporter The only plugin that performs a real export. isAvailable() checks native_observability_otel.settings:enabled and a non-empty endpoint. export() builds an OTLP JSON payload through OpenTelemetryPayloadBuilder, attaches an optional bearer token read from Drupal State, and issues a POST to the configured endpoint through the injected Guzzle client. It implements ContainerFactoryPluginInterface because it needs services beyond what the plugin base constructor provides.

Writing a custom exporter

A custom exporter is a class under your_module/src/Plugin/NativeObservabilityExporter/, decorated with the attribute, extending ExporterBase. The example below posts the payload as JSON to a webhook URL read from the module's own configuration.

my_module/src/Plugin/NativeObservabilityExporter/WebhookExporter.php:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Plugin\NativeObservabilityExporter;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\native_observability_otel\Attribute\NativeObservabilityExporter;
use Drupal\native_observability_otel\Plugin\NativeObservabilityExporter\ExporterBase;
use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Pushes completed traces to a configurable webhook URL.
 */
#[NativeObservabilityExporter(
  id: 'webhook',
  label: new TranslatableMarkup('Webhook exporter'),
  description: new TranslatableMarkup('Posts completed request traces as JSON to a configured webhook URL.'),
)]
final class WebhookExporter extends ExporterBase implements ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    string $plugin_id,
    mixed $plugin_definition,
    private readonly ClientInterface $httpClient,
    private readonly ConfigFactoryInterface $configFactory,
    private readonly LoggerInterface $logger,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('http_client'),
      $container->get('config.factory'),
      $container->get('logger.channel.my_module'),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function isAvailable(): bool {
    return (string) $this->configFactory->get('my_module.settings')->get('webhook_url') !== '';
  }

  /**
   * {@inheritdoc}
   */
  public function export(array $payload): bool {
    if (!$this->isAvailable()) {
      return FALSE;
    }

    $url = (string) $this->configFactory->get('my_module.settings')->get('webhook_url');

    try {
      $response = $this->httpClient->request('POST', $url, [
        'json' => $payload,
        'timeout' => 2.0,
      ]);

      return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300;
    }
    catch (\Throwable $throwable) {
      $this->logger->warning('Webhook export failed: @message', ['@message' => $throwable->getMessage()]);
      return FALSE;
    }
  }

}

Once my_module is enabled, webhook appears in ExporterRegistry::getDefinitions() and ExporterRegistry::getAvailableExporters() automatically, no explicit registration is needed beyond the attribute and the file's location. Nothing in native_observability_otel calls this plugin's export() for you: only OpenTelemetryTraceSubscriber calls ExporterRegistry::get('opentelemetry') on kernel.response. A custom plugin like this one needs its own subscriber (see Events) or its own call site to actually invoke export().