Collector & Sinks
A config-only swap point for your telemetry backend — sink drivers render a collector exporter fragment, never transport data themselves, with a crash-safe disk spool behind error delivery.
Collector & Sinks
The integration guides in this section cover connecting to an external tool directly. MetricsSinkInterface and ErrorSinkInterface exist for a different goal: making the choice of which backend you send telemetry to a one-line config change, with zero application code touching a vendor SDK.
Sinks render config, they don't transport
interface MetricsSinkInterface extends DriverInterface
{
public function name(): string;
public function signals(): array; // which of metrics/traces/logs this sink carries
public function endpoint(): SinkEndpoint; // the off-host endpoint the collector exports to
public function exporterConfig(): ExporterConfig; // the collector exporter fragment for this backend
}A sink driver is deliberately thin — it never sends telemetry itself. Your application emits OTLP to a loopback collector; the collector is the thing that actually forwards to Grafana, Datadog, or wherever. The sink's only job is declaring its capabilities for config-time validation and rendering the exporter fragment the collector needs to talk to that specific backend.
php bin/console vortos:observability:collectorThis generates the actual collector configuration file from whichever sink is selected. Switching grafana → datadog changes one config value — the generated collector exporter changes; your application code never does, because it was never aware of either backend to begin with.
Safety invariants baked into every generated config
CollectorConfigBuilder enforces several things as structural defaults rather than leaving them to whoever configures the collector by hand:
- The OTLP receiver binds loopback-only (
127.0.0.1). The app emits locally; the receiver is never exposed on a public interface, regardless of backend. memory_limiterandbatchare always present — the collector can't OOM the host it runs on, and exports are always bounded into batches.- Disk-buffered retry on the exporter — every sink's exporter gets
retry_on_failurebacked by afile_storageextension, so a backend blip doesn't drop telemetry; it queues to disk and retries. The generateddocker-compose.collector.yamlmounts a named volume for that queue and ships an init sidecar thatchowns it to the collector's non-root uid before the collector starts (a fresh named volume is root-owned, which would otherwise crash-loop the non-root collector onpermission denied); the collector waits for the init to complete. - High-cardinality attributes are stripped before export — a label that would explode your backend's cardinality (and your bill) never reaches it.
No backend name appears in the builder itself — only the selected sink's own exporterConfig() carries that, which is what keeps the generator genuinely backend-agnostic rather than secretly coupled to whichever vendor was implemented first.
Error delivery — crash-safe, never blocking
Sending an error to a tracking service synchronously on the request path is a liability: a slow or down error backend shouldn't make your actual request slower or fail it. BoundedSpool is the durability layer underneath ErrorSinkInterface that makes async, durable delivery safe:
emit(error)
│
▼ BoundedSpool::push() — never blocks, never throws
└── byte-capped FIFO on disk
full? → drop the OLDEST record, increment droppedTotal, admit the new one
│
▼ (separately) drain to the real backend, with retryThe spool is byte-capped, not unbounded — during a long backend outage it drops the oldest queued errors rather than filling the disk and taking the host down with it. Every record is length-prefixed and CRC32-checked, so a process crash mid-write leaves a detectable, truncatable torn tail instead of corrupting the whole queue on the next read. Every mutating operation holds an exclusive flock, making the spool safe across multiple worker processes writing to the same file.
Message scrubbing — closing the most common real leak
final class MessageScrubber
{
// patterns ordered most-specific → most-general, so a JWT isn't
// eaten by a generic long-token rule before the JWT-specific one runs
}The single most common way PII or secrets end up in a third-party error tracker is shipping a stack trace or log message verbatim — a bearer token, an email address, a JWT, a card number embedded in an exception message. MessageScrubber redacts these patterns before a CapturedError ever leaves the process. The patterns are deliberately conservative in one direction: false positives (over-redaction) are an acceptable cost; false negatives (an actual leak) are not.
Same philosophy as Secrets and Analytics
This is the same defense-in-depth posture as Secrets' PiiRedactor and Analytics' privacy filtering — redact independently of whatever upstream configuration exists, so a misconfiguration elsewhere doesn't translate directly into a leak.
Driver-kit pattern
Both ports follow Ops Kit exactly — #[AsDriver], a registry, a TCK. Implementing a sink for a backend that isn't shipped yet means implementing the port and rendering that backend's OTLP exporter config; there's no other integration surface to touch.
Integrations
Connect Vortos to external observability, alerting, and monitoring tools — Sentry, Grafana, Prometheus, Loki, Tempo, Jaeger, Datadog, Slack, and Alertmanager.
Deploy Audit Ledger
A hash-chained, HMAC-signed, append-only record of every deploy and rollback — verifiable and exportable independent of the underlying database's own integrity.