Skip to content

Observability: hash-chained SIEM log and render reporting

The Observability module provides the runtime-state implementation: a tamper-evident hash-chained security information and event management (SIEM) event log, render and pilot report aggregation, a hardware security module (HSM) audit log, and complete no-op metrics and trace implementations so instrumentation is always callable.

One canonical page per concern. The observability contractsContextAwareExceptionInterface, SpectrumInterface, JobNotificationInterface, and the DegradationPolicy enum — are documented on Contracts / Observability. This page documents the concrete runtime-state implementation. The pages are complementary, not duplicates: use the contracts page for the service provider interface (SPI), and use this page for the SIEM log, reporting, and audit surfaces.

Terminal window
composer require nextpdf/core:^3

This module turns the engine’s runtime state into durable, verifiable output.

HashChainSiemEventLog is the security-grade surface. It implements the SiemEventEmitter contract and writes a JavaScript Object Notation (JSON) Lines log where each record’s hash is SHA-256(prev_hash_bytes || canonical_event_bytes). That linear hash-chain makes the log tamper-evident: if any byte changes, a line is deleted, or lines are reordered, the chain breaks. verifyIntegrity() walks the file and returns the index of the first inconsistent record, or null when the chain is intact. readAll() streams the records. A per-process advisory flock(LOCK_EX) protects the read-tail-then-append critical section, so concurrent PHP processes on the same file do not interleave records. The bound is explicit: this is a linear hash-chain, not a Request for Comments (RFC) 6962 Merkle tree. It is sufficient for tamper-evidence, not for efficient inclusion proofs. The source says so. SiemEvent carries the typed event with toCanonicalJson(). SiemEventSeverity and SiemEventType classify it. CorrelationContext and CorrelationIdGenerator carry a correlation id across related events.

RenderReportBuilder, RenderReport, PilotReportAggregator, and PilotSummary make up the reporting surface (@since 5.1.0). The aggregator collects RenderReports and produces a PilotSummary that renders to array, JSON, or Markdown, in the form an operations review can use.

HsmAuditLogInterface / HsmAuditEvent record HSM-backed signing operations for the security layer. The MetricsCounterInterface, MetricsGaugeInterface, MetricsHistogramInterface, and TraceSpanInterface define the metrics and trace shapes. The NoOp* implementations provide a complete inert fallback, so the engine can emit metrics and spans without a configured backend.

Stability: experimental. The SIEM log is internally cycle-tagged rather than carrying a frozen semantic versioning (semver) @since, and the reporting surface is @since 5.1.0. The surfaces are functional and tested, but the application programming interface (API) shapes may evolve. Treat the log format (canonical JSON + hash-chain) as the stable contract and the PHP API as still settling.

ClassKey membersRole
HashChainSiemEventLogemit(SiemEvent), verifyIntegrity(): ?int, readAll(): GeneratorTamper-evident hash-chained SIEM log
SiemEventtoCanonicalJson()Typed SIEM event
SiemEventSeverity / SiemEventType (enums)classificationClassifies event severity and type
CorrelationContext / CorrelationIdGeneratorcorrelation threadingThreads correlation through related events
RenderReportBuilder / RenderReportreport assemblyBuilds per-render reports (@since 5.1.0)
PilotReportAggregatoraddReport(), count(), getSummary(), toJson(), toMarkdown(), exportReportsJson()Aggregates render reports (@since 5.1.0)
PilotSummarytoArray(), toJson(), toMarkdown()Summarizes operations-review output (@since 5.1.0)
HsmAuditLogInterface / HsmAuditEventHSM audit recordRecords HSM operation audits
NoOpSiemEventEmitter, NoOpMetricsCounter, NoOpTraceSpan, …inert fallbacksProvides complete no-op implementations

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

Emit an event and verify the integrity of the log.

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Observability\Siem\HashChainSiemEventLog;
use NextPDF\Observability\Siem\SiemEvent;
$log = new HashChainSiemEventLog('/var/log/nextpdf/siem.jsonl');
$log->emit(new SiemEvent(/* type, severity, payload */));
$firstBroken = $log->verifyIntegrity();
echo $firstBroken === null
? "SIEM chain intact.\n"
: "Tamper detected at record {$firstBroken}.\n";

Wrap the emitter so a logging failure in the signing hot path stays a local decision instead of becoming an uncaught exception.

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Observability\Siem\HashChainSiemEventLog;
use NextPDF\Observability\Siem\Exception\SiemEmitterException;
use NextPDF\Observability\Siem\SiemEvent;
use Psr\Log\LoggerInterface;
final readonly class AuditedSiemSink
{
public function __construct(
private HashChainSiemEventLog $log,
private LoggerInterface $fallback,
) {}
public function record(SiemEvent $event): void
{
try {
$this->log->emit($event);
} catch (SiemEmitterException $e) {
// Do not let SIEM I/O abort the signing path; record and continue.
$this->fallback->critical('SIEM emit failed; event not chained.', [
'error' => $e->getMessage(),
]);
}
}
}
  • emit() throws SiemEmitterException on write errors. A caller in the signing hot path must wrap it and decide locally whether to swallow, retry, or abort. The emitter does not decide for you.
  • verifyIntegrity() returns the index of the first broken record, or null. A non-null result means the log is compromised from that point. Do not trust records at or after it.
  • The advisory flock is per-process and same-file. Cross-host concurrency needs an out-of-band sink, such as syslog forwarding. Do not assume the file lock coordinates across machines.
  • This is a linear hash-chain, not a Merkle tree. It provides tamper-evidence, not efficient inclusion proofs. Do not market it as the latter.
  • The NoOp* fallbacks are complete and inert. Do not branch on backend availability to “save work”. The no-op already costs nothing.

emit() reads the previous record’s hash and appends one line under a file lock: O(1) per event plus the lock. verifyIntegrity() is O(n) in the record count because it walks the whole chain. Run it on a schedule, not in the hot path. Reporting aggregation is linear in the number of reports. The reproducibility profile is structural: events and reports carry timestamps and correlation ids, so two runs differ in those fields while the chain structure remains deterministic.

The SIEM log is a security control. Its tamper-evidence depends on protecting both the log file and the verification step: store the file on append-friendly, access-controlled storage, run verifyIntegrity() on a schedule, and forward records out-of-band so a host compromise cannot silently rewrite history. Events can carry sensitive context. Apply the project’s log-scrubbing obligation before constructing the event, not after chaining it, because a scrubbed rewrite would break the chain. The HSM audit log records signing operations and is itself security-relevant. Treat it with the same protections. See the engine threat model in /modules/core/security/.

This module makes no normative claim about PDF specifications. It implements log-integrity and observability mechanisms whose design aligns with the log-management and integrity-verification practices in NIST SP 800-92. That control-framework alignment is documented in the source; it is not a chunk-pinned PDF citation. The conformance of documents the engine produces is validated by the oracle and golden suites described in /modules/core/conformance/.