Enterprise edition
Metering — Deep Reference
At a glance
Section titled “At a glance”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.
Availability & licensing
Section titled “Availability & licensing”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.
Public API surface
Section titled “Public API surface”| Symbol | Parameters | Default behavior | Returns | Throws or fails with | Notes |
|---|---|---|---|---|---|
MeterCollector::__construct | MeteringReporter $reporter, int $bufferSize = 100 | Creates a collector with an empty in-memory buffer | New MeterCollector | Does not throw | $bufferSize is documented positive-int |
MeterCollector::record | string $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 $bufferSize | void | Does not throw; an auto-flush delegates to the reporter, which never throws | Timestamp is taken at record time |
MeterCollector::flush | — | Hands all buffered entries to the reporter; empty buffer is a no-op | void | Does not throw; backend failures are absorbed by the reporter | Buffer is swapped out before hand-off; re-entrant safe |
MeterCollector::bufferCount | — | Returns the number of buffered entries | int<0, max> | Does not throw | Diagnostics and back-pressure decisions |
MeterCollector::registerShutdownFlush | — | Registers flush() through register_shutdown_function | void | Does not throw | Call once at bootstrap in PHP-FPM deployments |
MeterEntry::__construct | string $operation, int $count, DateTimeImmutable $timestamp, string $tenantId, string $licenseId, int $pagesProcessed = 0, float $durationMs = 0.0, array $metadata = [] | Stores the supplied values verbatim | New MeterEntry | No declared @throws; PHP raises TypeError on mismatched argument types under strict_types | final readonly; all eight promoted properties are public |
MeteringReporter::__construct | list<MeteringBackendInterface> $backends, int $maxRetries = 2, LoggerInterface $logger = new NullLogger() | Validates and stores the backend list | New MeteringReporter | InvalidArgumentException when $backends is empty | $maxRetries counts total delivery attempts per backend |
MeteringReporter::report | list<MeterEntry> $entries | Delivers the batch to every backend independently, with per-backend retry | void | Does not throw; exhausted attempts log at error level and drop that backend’s batch | Empty list is a no-op |
MeteringBackendInterface::report | list<MeterEntry> $entries | Delivers a batch to the backend | void | RuntimeException when the backend is unreachable | Implementations MUST be idempotent (dedupe by timestamp + operation + tenantId) |
MeteringBackendInterface::isHealthy | — | Reachability probe | bool | No declared @throws | Diagnostics only; the reporter does not gate on it |
MeteringBackendInterface::backendName | — | Diagnostic backend name | non-empty-string | No declared @throws | For example "prometheus", "billing-api", "null" |
PrometheusMeteringBackend::__construct | ClientInterface $httpClient, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, string $pushgatewayUrl, string $jobName = 'nextpdf_metering' | Configures a Pushgateway push target | New PrometheusMeteringBackend | Does not throw | PSR-18 client and PSR-17 factories are injected |
PrometheusMeteringBackend::report | list<MeterEntry> $entries | Aggregates the batch by operation-and-tenant series and POSTs exposition text to <pushgatewayUrl>/metrics/job/<jobName> | void | PrometheusPushgatewayException on a non-2xx status or a PSR-18 transport failure | Empty list is a no-op |
PrometheusMeteringBackend::isHealthy | — | Probes the Pushgateway health endpoint; true only on HTTP 200 | bool | Does not throw; any failure returns false | Read-only GET probe |
PrometheusMeteringBackend::backendName | — | Returns "prometheus" | non-empty-string | Does not throw | Constant |
PrometheusPushgatewayException | — | Signals a failed Pushgateway delivery | — | Is the throwable | final; 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(): voidpublic 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): voidpublic 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
| Property | Type | Meaning |
|---|---|---|
$operation | non-empty-string | Operation type, for example "parse", "compress", "embed", "rag_query" |
$count | positive-int | Number of units consumed |
$timestamp | DateTimeImmutable | When the operation occurred; the collector stamps it at record time |
$tenantId | non-empty-string | Tenant identifier |
$licenseId | non-empty-string | License identifier |
$pagesProcessed | int<0, max> | PDF pages processed; 0 for non-PDF operations |
$durationMs | float | Operation duration in milliseconds |
$metadata | array<string, mixed> | Free-form operation-specific metadata |
Behavior contract
Section titled “Behavior contract”MeterCollector::record()constructs one immutableMeterEntry, stamps it with the current time, and appends it to the in-memory buffer. When the buffer reaches$bufferSizeentries, 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.MeteringReporterrejects construction with an empty backend list. ThatInvalidArgumentExceptionis 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.$maxRetriescounts total delivery attempts per backend; the default of2means 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-Typetext/plain; version=0.0.4. The default job name isnextpdf_metering.- The pushed payload carries three counters —
nextpdf_operations_total,nextpdf_pages_processed_total, andnextpdf_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.
Edge cases & failure modes
Section titled “Edge cases & failure modes”- 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()orflush()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. $bufferSizebelow1. Violates the documentedpositive-intcontract; the observable result is a flush on everyrecord()call.- Sensitive metadata.
$metadatais 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
PrometheusPushgatewayExceptioncarrying 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>/-/healthyand returnstrueonly on HTTP 200. Any transport error returnsfalse; 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.
Conformance
Section titled “Conformance”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.
Development notes
Section titled “Development notes”- All classes declare
strict_types=1and arefinal;MeterEntryisfinal readonlywith promoted public properties. Mismatched argument types raise a PHPTypeErrorin the caller. - The module classes carry a package
@sinceannotation of2.1.0;PrometheusPushgatewayExceptioncarries@since3.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
MeteringBackendInterfaceand constructMeterEntryvalues 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.
Publication boundary
Section titled “Publication boundary”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.
See also
Section titled “See also”- Metering — NextPDF Enterprise — the capability page: workflow, configuration, and worked deployment examples.
- Billing — Deep Reference — plan tiers, overage semantics, and the alert ladder.
- SaaS — Deep Reference — the multi-tenant orchestration surface.
- Licensing — Deep Reference — the license envelope that activates Enterprise capabilities.