Skip to content

Telemetry: OpenTelemetry bridge and no-op fallback

The Telemetry module is the engine’s optional bridge to the OpenTelemetry (OTel) software development kit (SDK). When the SDK is installed, it emits spans and metrics with sanitized attributes. When the SDK is absent, a complete set of no-op tracer and meter objects keeps instrumentation calls valid and effectively free. You can safely leave instrumentation in the code path.

Terminal window
composer require nextpdf/core:^3

The design goal is observability with zero cost when the SDK is absent. The engine’s hot paths call a tracer and a meter without checking first. Whether those calls do work depends on the runtime, not on a conditional at each call site.

OpenTelemetryInterceptor is the bridge. isAvailable() reports whether the OTel SDK is present. startSpan(string $name, array $attributes = []) / endSpan(?object $span) bracket a traced operation, and recordMetric() records a counter or gauge value. When OTel is absent, the interceptor reports unavailable, and the calls are inert. TelemetryBridge wires the interceptor into the engine’s observation points.

AttributeSanitizer is the safety layer. sanitize(array $attributes) scrubs the attribute map before it leaves the process. Telemetry attributes are a common accidental personally identifiable information (PII) channel, so sanitization is part of the contract, not an add-on. The sanitizer, interceptor, and bridge are @since 2.3.0.

NullTracer, NullSpanBuilder, NullSpan, NullMeter, NullCounter, and NullHistogram are the no-op fallback. They match the call shapes the OTel SDK exposes: spanBuilder(), setAttribute() (chainable), startSpan(), end(), createHistogram(), createUpDownCounter(), add(), and record(). They do nothing. Because the fallback is complete, instrumented code does not branch on availability; it calls the tracer, and the no-op absorbs the call.

ClassKey membersRole
OpenTelemetryInterceptorisAvailable(), startSpan(), endSpan(), recordMetric()OTel span and metric bridge (@since 2.3.0)
TelemetryBridgeengine wiringConnects the interceptor to engine observation points (@since 2.3.0)
AttributeSanitizersanitize(array $attributes): arrayScrubs attributes for PII safety (@since 2.3.0)
NullTracerspanBuilder(string $name): NullSpanBuilderNo-op tracer
NullSpanBuildersetAttribute(), startSpan(): NullSpanNo-op span builder (chainable)
NullSpanend()No-op span
NullMetercreateHistogram(), createUpDownCounter()No-op meter
NullCounter / NullHistogramadd(), record()No-op instruments

Run composer docs:generate-api-php -- --module=Telemetry to generate the full PHPDoc table.

Source: examples/33-opentelemetry-observability.php.

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Telemetry\OpenTelemetryInterceptor;
$otel = new OpenTelemetryInterceptor(/* optional OTel tracer/meter */);
$span = $otel->startSpan('pdf.render', ['doc.pages' => 12]);
// ... render work ...
$otel->endSpan($span);
$otel->recordMetric('pdf.render.bytes', 482_113, ['profile' => 'pdfa4']);

When the OTel SDK is absent, every call above is a no-op. The code stays identical, and the cost is zero.

Wrap a render operation with sanitized attributes so caller-supplied metadata cannot leak into a span.

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Telemetry\AttributeSanitizer;
use NextPDF\Telemetry\OpenTelemetryInterceptor;
final readonly class InstrumentedRenderer
{
public function __construct(
private OpenTelemetryInterceptor $otel,
private AttributeSanitizer $sanitizer,
) {}
/**
* @param callable():string $render Returns the rendered PDF bytes.
* @param array<string, mixed> $attributes Caller-supplied span attributes.
*/
public function render(callable $render, array $attributes): string
{
$span = $this->otel->startSpan('pdf.render', $this->sanitizer->sanitize($attributes));
try {
return $render();
} finally {
$this->otel->endSpan($span);
}
}
}
  • The no-op fallback is complete by design. Do not guard instrumentation with isAvailable() “to save work”. The no-op already costs nothing, and the guard adds the branch this design removes.
  • Always run caller-supplied attributes through AttributeSanitizer before you attach them to a span or metric. Telemetry attributes are an accidental PII channel.
  • endSpan(null) is valid: a null span is the no-op case. Pair every startSpan() with an endSpan() in a finally.
  • NullSpanBuilder::setAttribute() returns static for chaining. Under the no-op, the chain is inert by design.
  • The reproducibility profile is structural: spans carry timestamps and trace identifiers, so two runs differ in those fields.

When OTel is absent, the cost is a method call into a no-op, effectively free. When OTel is present, the cost comes from the OTel SDK; the bridge adds attribute sanitization, which is linear in the attribute count. The performance_budget of 1500 ms wall / 64 MB peak is the engine reference budget, not a telemetry service-level agreement (SLA).

Telemetry is a data-egress surface. AttributeSanitizer keeps secrets and PII out of spans and metrics. Treat sanitization as mandatory for any caller-influenced attribute; that is the project’s safe-telemetry obligation. The OTel exporter sends data to an external backend, and that backend is a trust boundary. Configure its endpoint and credentials from a secret manager, not committed config. Assume span and metric data reaches a log sink, and scrub it accordingly. See the engine threat model in /modules/core/security/.

This module makes no Portable Document Format (PDF) specification normative claim. It bridges to the OpenTelemetry data model, an external observability specification, not a PDF clause. The no-op fallback mirrors the OpenTelemetry application programming interface (API) surface so instrumented code stays portable. That is an API-compatibility property, not a PDF conformance statement. Engine conformance is validated by the oracle and golden suites described in /modules/core/conformance/.