Skip to content
getnextpdf.com

Enterprise edition

Metering — Deep Reference

The NextPDF\Enterprise\Metering namespace ships orchestration-level usage metering for billing visibility and audit. The public surface is six symbols: MeterCollector, MeterEntry, MeteringReporter, MeteringBackendInterface, PrometheusMeteringBackend, and PrometheusPushgatewayException. The collector buffers immutable entries in memory and flushes them in batches. The reporter fans each batch out to one or more backends with per-backend retry and failure isolation. Metering is best-effort and non-fatal: a metering-backend outage degrades observability, never document processing. This stream is not the authoritative source for quota enforcement. For the workflow-level guide, see Metering.

This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.

Metering is a base Enterprise capability, available once the Enterprise package is installed; there is no separate per-feature flag. NextPDF Core (Apache-2.0) and NextPDF Pro have no collector, reporter, or backend surface; the contract ships in nextpdf/enterprise only.

SymbolParametersDefault behaviorReturnsThrows or fails withNotes
MeterCollector::__constructMeteringReporter $reporter, int $bufferSize = 100Creates a collector with an empty in-memory bufferNew MeterCollectorDoes not throw$bufferSize is documented positive-int
MeterCollector::recordstring $operation, int $count, string $tenantId, string $licenseId, int $pagesProcessed = 0, float $durationMs = 0.0, array $metadata = []Appends one immutable MeterEntry stamped with the current time; auto-flushes when the buffer reaches $bufferSizevoidDoes not throw; an auto-flush delegates to the reporter, which never throwsTimestamp is taken at record time
MeterCollector::flushHands all buffered entries to the reporter; empty buffer is a no-opvoidDoes not throw; backend failures are absorbed by the reporterBuffer is swapped out before hand-off; re-entrant safe
MeterCollector::bufferCountReturns the number of buffered entriesint<0, max>Does not throwDiagnostics and back-pressure decisions
MeterCollector::registerShutdownFlushRegisters flush() through register_shutdown_functionvoidDoes not throwCall once at bootstrap in PHP-FPM deployments
MeterEntry::__constructstring $operation, int $count, DateTimeImmutable $timestamp, string $tenantId, string $licenseId, int $pagesProcessed = 0, float $durationMs = 0.0, array $metadata = []Stores the supplied values verbatimNew MeterEntryNo declared @throws; PHP raises TypeError on mismatched argument types under strict_typesfinal readonly; all eight promoted properties are public
MeteringReporter::__constructlist<MeteringBackendInterface> $backends, int $maxRetries = 2, LoggerInterface $logger = new NullLogger()Validates and stores the backend listNew MeteringReporterInvalidArgumentException when $backends is empty$maxRetries counts total delivery attempts per backend
MeteringReporter::reportlist<MeterEntry> $entriesDelivers the batch to every backend independently, with per-backend retryvoidDoes not throw; exhausted attempts log at error level and drop that backend’s batchEmpty list is a no-op
MeteringBackendInterface::reportlist<MeterEntry> $entriesDelivers a batch to the backendvoidRuntimeException when the backend is unreachableImplementations MUST be idempotent (dedupe by timestamp + operation + tenantId)
MeteringBackendInterface::isHealthyReachability probeboolNo declared @throwsDiagnostics only; the reporter does not gate on it
MeteringBackendInterface::backendNameDiagnostic backend namenon-empty-stringNo declared @throwsFor example "prometheus", "billing-api", "null"
PrometheusMeteringBackend::__constructClientInterface $httpClient, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, string $pushgatewayUrl, string $jobName = 'nextpdf_metering'Configures a Pushgateway push targetNew PrometheusMeteringBackendDoes not throwPSR-18 client and PSR-17 factories are injected
PrometheusMeteringBackend::reportlist<MeterEntry> $entriesAggregates the batch by operation-and-tenant series and POSTs exposition text to <pushgatewayUrl>/metrics/job/<jobName>voidPrometheusPushgatewayException on a non-2xx status or a PSR-18 transport failureEmpty list is a no-op
PrometheusMeteringBackend::isHealthyProbes the Pushgateway health endpoint; true only on HTTP 200boolDoes not throw; any failure returns falseRead-only GET probe
PrometheusMeteringBackend::backendNameReturns "prometheus"non-empty-stringDoes not throwConstant
PrometheusPushgatewayExceptionSignals a failed Pushgateway deliveryIs the throwablefinal; extends RuntimeException
public function __construct(
private readonly MeteringReporter $reporter,
private readonly int $bufferSize = 100,
) {}
public function record(
string $operation,
int $count,
string $tenantId,
string $licenseId,
int $pagesProcessed = 0,
float $durationMs = 0.0,
array $metadata = [],
): void
public function flush(): void
public function bufferCount(): int
public function registerShutdownFlush(): void
public function __construct(
public string $operation,
public int $count,
public DateTimeImmutable $timestamp,
public string $tenantId,
public string $licenseId,
public int $pagesProcessed = 0,
public float $durationMs = 0.0,
public array $metadata = [],
) {}
public function report(array $entries): void;
public function isHealthy(): bool;
public function backendName(): string;
public function __construct(
array $backends,
private readonly int $maxRetries = 2,
private readonly LoggerInterface $logger = new NullLogger(),
)
public function report(array $entries): void
public function __construct(
private readonly ClientInterface $httpClient,
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
private readonly string $pushgatewayUrl,
private readonly string $jobName = self::DEFAULT_JOB_NAME,
) {}
final class PrometheusPushgatewayException extends RuntimeException {}

MeterEntry public readonly properties

PropertyTypeMeaning
$operationnon-empty-stringOperation type, for example "parse", "compress", "embed", "rag_query"
$countpositive-intNumber of units consumed
$timestampDateTimeImmutableWhen the operation occurred; the collector stamps it at record time
$tenantIdnon-empty-stringTenant identifier
$licenseIdnon-empty-stringLicense identifier
$pagesProcessedint<0, max>PDF pages processed; 0 for non-PDF operations
$durationMsfloatOperation duration in milliseconds
$metadataarray<string, mixed>Free-form operation-specific metadata
  • MeterCollector::record() constructs one immutable MeterEntry, stamps it with the current time, and appends it to the in-memory buffer. When the buffer reaches $bufferSize entries, the collector auto-flushes.
  • flush() is idempotent and re-entrant safe. An empty buffer is a no-op. The buffer is swapped out before the batch is handed to the reporter, so a re-entrant flush cannot double-send.
  • MeteringReporter rejects construction with an empty backend list. That InvalidArgumentException is the only exception on the collector/reporter path.
  • MeteringReporter::report() delivers each batch to every backend independently. A failing backend never prevents another backend from receiving the same batch.
  • $maxRetries counts total delivery attempts per backend; the default of 2 means one initial attempt plus one retry. Every failed attempt logs a warning with the backend name, attempt number, and entry count.
  • When the final attempt for a backend fails, the reporter additionally logs at error level with the dropped-entry count, then moves on. It never throws from report(), so callers must not infer delivery from a normal return.
  • Backends MUST be idempotent. The interface contract requires deduplication keyed on timestamp, operation, and tenant identifier. The reporter itself does not deduplicate.
  • PrometheusMeteringBackend::report() aggregates the batch into per-operation, per-tenant series and POSTs Prometheus text exposition to <pushgatewayUrl>/metrics/job/<jobName> with Content-Type text/plain; version=0.0.4. The default job name is nextpdf_metering.
  • The pushed payload carries three counters — nextpdf_operations_total, nextpdf_pages_processed_total, and nextpdf_operation_duration_ms_total — each labeled by operation and tenant.
  • This metering stream is non-authoritative. Quota enforcement and authoritative compute metering consume the deployment’s separate authoritative usage figure, never this buffer. A gap in orchestration metering is an observability gap, not a billing-correctness gap.
  • Duplicated or replayed batch. Absorbed by backend idempotency; the reporter does not deduplicate. Do not rely on exactly-once delivery.
  • Exhausted retries. The batch for that backend is dropped and logged at error level. A normal return from report() or flush() never implies delivery.
  • Process exit before flush. The buffer is memory-only. A crash, or an exit without a registered shutdown handler, loses the buffered entries.
  • Worker-model mismatch. PHP-FPM deployments call registerShutdownFlush() once at bootstrap so the remainder flushes at request end. Long-running workers (Octane, Symfony worker, queue worker) must flush on a periodic timer instead; otherwise entries accumulate until the worker process exits.
  • $bufferSize below 1. Violates the documented positive-int contract; the observable result is a flush on every record() call.
  • Sensitive metadata. $metadata is free-form and may carry sensitive operation context. Storage, retention, and access control are the backend operator’s responsibility.
  • Pushgateway delivery failure. A non-2xx response raises PrometheusPushgatewayException carrying the HTTP status and response body; a PSR-18 transport failure is wrapped in the same exception type. The reporter’s retry-and-isolation loop absorbs both.
  • Health probe. PrometheusMeteringBackend::isHealthy() issues a GET against <pushgatewayUrl>/-/healthy and returns true only on HTTP 200. Any transport error returns false; the probe never throws.
  • Hostile label values. Backslash, double-quote, and line-feed characters in operation or tenant values are escaped at emission, so a label value cannot inject additional exposition lines or corrupt the label block.
  • FIPS-mode. The collector and reporter perform no cryptographic operations and have no FIPS-specific behavior. A backend that signs or encrypts in transit inherits its host crypto provider’s FIPS posture.

The Prometheus backend emits the Prometheus text exposition format and pushes with Content-Type text/plain; version=0.0.4; that format is an ecosystem convention rather than an ISO or IETF standard.

  • All classes declare strict_types=1 and are final; MeterEntry is final readonly with promoted public properties. Mismatched argument types raise a PHP TypeError in the caller.
  • The module classes carry a package @since annotation of 2.1.0; PrometheusPushgatewayException carries @since 3.2.0.
  • The reporter’s logger defaults to a PSR-3 NullLogger. Inject a real logger in production, or dropped batches leave no trace.
  • Unit testing: implement a fake MeteringBackendInterface and construct MeterEntry values directly. The Prometheus backend takes PSR-18/PSR-17 abstractions, so a mock HTTP client exercises the full push path offline.
  • Recommended boundary tests: buffer exactly at $bufferSize, re-entrant flush, empty-buffer flush, one backend failing while a second succeeds, and retry-exhaustion logging.
  • Backend implementers throw RuntimeException (or a subclass) on delivery failure; the reporter absorbs it. Honor the idempotency requirement before adding further retries upstream.

This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.