build-gaia / php-collector
Chronos telemetry collector SDK for PHP. Thin span-decoration API over the native Rust extension.
Requires
- php: >=8.1
Requires (Dev)
None
Suggests
- bunny/bunny: Lets BunnyTelemetry propagate traces across an AMQP publish and trace consumed messages
- doctrine/dbal: Lets the Doctrine ChronosMiddleware (DBAL 3/4) or ChronosSqlLogger (DBAL 2) emit SQL client spans
- laravel/framework: Required for automatic Laravel HTTP instrumentation
- monolog/monolog: Lets ChronosHandler ship Monolog records to the collector's log pipeline
- psr/http-client: Lets ChronosClientDecorator wrap any PSR-18 client with spans and trace propagation
- psr/log: Lets ChronosLogger decorate any PSR-3 logger to capture its records
- symfony/framework-bundle: Required for Symfony HTTP instrumentation
- symfony/http-client: Lets ChronosHttpClient decorate Symfony's HTTP client with spans and trace propagation
- symfony/messenger: Lets the Messenger ChronosMiddleware propagate traces across buses and trace message handling
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-16 14:02:53 UTC
README
Observability for PHP applications: APM traces, continuous profiling, full HTTP
stack capture, log correlation, and deterministic-simulation recording — collected
by a native extension (chronos.so), spooled to a shared volume, and drained
automatically by the Chronos Docker stack's collector.
Designed to be safe to bake into every image: with CHRONOS_PHP_ENABLED=0,
both the extension and this package cost your application nothing. Set it to 0
explicitly rather than leaving it unset — see Cost guarantees
for the one thing that is not free by default.
build-gaia/php-collectoris a read-only publishing mirror. The source lives atbuild-gaia/collectorundersdks/php, and is split out to this repository on every push so Packagist findscomposer.jsonat a repository root. A commit made here is overwritten by the next split — open pull requests againstbuild-gaia/collectorinstead.
Install
The whole contract is three steps, and nothing else: drop in chronos.so, add a
.chronos file, composer require build-gaia/php-collector. That is true for a
bare extension install (traces/profiles/HTTP capture, no PHP code) and it is true
with the framework package too — Laravel wires itself in via Composer's package
auto-discovery, and Symfony either picks up the bundle automatically through a
Flex recipe or needs the one documented line in config/bundles.php (see step 3).
Nothing beyond these three steps is required for either framework.
1. The native extension
Grab the chronos.so matching your PHP version and libc from the releases page
(builds exist for PHP 8.1 / 8.2 / 8.4, glibc and musl), or build it yourself with
make php-extension in the collector repo. Then, in your Dockerfile /
Dockerfile.dev:
# PHP 8.2 (Debian/bookworm images). For Alpine images use the -musl build. COPY chronos.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/chronos.so RUN echo "extension=chronos.so" > /usr/local/etc/php/conf.d/90-chronos.ini
Extension-dir names per version: 8.1 → 20210902, 8.2 → 20220829,
8.4 → 20240924 (or just install to $(php-config --extension-dir)).
The extension alone gives you: a root span per request, automatic curl / PDO /
mysqli / Redis / Memcached client spans with W3C traceparent propagation,
headers/cookies/query/body capture (redacted at capture time), the sampling
profiler, and deploy tracking from your .git. No PHP code required.
2. Configuration
Create a .chronos file at your project root (found automatically by walking up
from the document root):
# Identity: all four are required; without them the collector stays inert. enabled=1 organisation=org_a2a69137-6d90-49cc-90b9-5d3e49f1ef96 team_id=my-team application=my-app # The mounted chronos-spool volume, see step 4. Comments must sit on their own # line: the parser does not strip a trailing `# ...` from a value, so an inline # comment would become part of the path. spool_directory=/var/lib/chronos/spool # Capture tiers. Rates are fractions: 1 = all, 0.1 = a tenth. All default to off # EXCEPT the counted profile (`profile_deterministic`), which is on — see below. apm_enabled=1 apm_sample_rate=1 # trace every request profiler_enabled=1 profile_request_rate=0 # web requests: profile on demand only profile_job_rate=0.1 # background jobs: a tenth of them logs_enabled=1
Every setting can equally be set as an environment variable
(CHRONOS_PHP_ENABLED=1, CHRONOS_PHP_ORGANISATION=my-org, …) or in php.ini
(chronos.enabled=1, chronos.organisation=my-org, …). Precedence:
env > php.ini > .chronos file. The .chronos file accepts both spellings
(enabled=1 and CHRONOS_PHP_ENABLED=1), so it can double as a dotenv include.
That precedence is the whole security model, not an implementation detail: the
.chronos file ships with your application's own code, so anyone who can edit it
can already run code in the process — trusting it grants no new capability. Env
and php.ini, by contrast, are set by whoever controls the platform underneath the
application, and a value pinned at that layer can never be overridden by a file
the application ships. On top of the ordering, organisation / team_id /
project / application read from the .chronos file are validated as
identifier-shaped ([A-Za-z0-9._-], 1–128 bytes) and spool_directory as an
absolute path with no .. segment; a value that fails validation is treated as
absent (never half-applied) and logged once, by key name only — env and php.ini
values are trusted as given and never validated.
3. The PHP package (optional, recommended)
composer require build-gaia/php-collector
The extension works without it; the package adds what only userland can know:
-
Laravel — zero configuration: the service provider auto-discovers, and you get route pattern/name/action/middleware on the request span, SQL spans with connection identity and bound parameters, cache hit/miss/write/forget spans with the hit value, log→trace correlation, exact response capture, bounded
framework.views/.models/.mail/.authorizationcounts, authenticated user id and peak memory, plus themessaging.events/messaging.jobscatalogs.Spans, not just counts, for the work that takes the time: template renders (one span per view, nested the way
@includenests them), transaction boundaries (the queries inside nest under theBEGIN, and a transaction the request abandoned is closed and marked rather than dropped), Redis commands the cache layer did not issue — the facade, locks, sessions, the rate limiter — and framework bootstrap, measured fromLARAVEL_STARTto the container'sbootedcallback. Every exception the application reports becomes a span too, including the ones it recovered from and never showed the user; the request root carries only the one that reached the response.Queued jobs are one trace, end to end. The W3C context is stamped into the job payload at dispatch (through Laravel's own
createPayloadUsingseam, so it survives retries, releases and any queue driver), and the worker opens a job-scoped request against it — so the job's queries, HTTP calls and failures hang beneath the request that queued it. This needs noCHRONOS_PHP_CLI_ENABLED: that flag gates only the extension's AUTOMATIC start in RINIT, and the bridge starts each job's request explicitly. Leave it off — with it on, the worker PROCESS gets a request of its own and the first job of each worker inherits everything since boot. What the worker does need is this package in its vendor tree; the extension alone carries no queue instrumentation.The dispatch instant rides in the payload beside the context, so the job root also carries
messaging.message.queue_time_ms: how long the message waited, which is the one number that separates a backed-up queue from a slow job — they produce identical job durations. It is dispatch-to-start (a deliberately delayed job counts its delay as wait; the intent is on the dispatching request as itsmessaging.jobsrecord'sdelay_ms), and it is absent rather than zero when the wait cannot be proven — an older payload with no stamp, or a dispatcher whose clock runs ahead of the worker's.The catalogs are the part worth reading twice. An event or a queued job is recorded with its destination, transport, encoding and dispatch call site, not just its name — and a message that actually leaves the process (a broadcast event, a job on any driver but
sync) additionally gets its own producer span carryingmessaging.system/messaging.destination.name/messaging.operation, which is what draws the edge to the broker on the service map. See ADR 0024 for the vocabulary and its bounds. -
bunny/bunny (raw AMQP) —
BunnyTelemetryjoins a publish and a consume into one trace where there is no framework seam to hook: no envelope, no stamp, no event, just a channel method and a delivery callback. Two call sites change.$channel->publish($body, $headers, $exchange, $routingKey)becomesBunnyTelemetry::publish($channel, $body, $headers, $exchange, $routingKey, vhost: $vhost, messageName: $class, contentType: 'application/x-protobuf')— the first six arguments are Bunny's own signature verbatim, and the trailing ones are the facts Bunny cannot supply (its$optionsis protected with no getter, so the vhost is unreachable from aChannel). And$channel->consume($callback, $queue)becomes$channel->consume(BunnyTelemetry::consumer($callback, $queue, $vhost), $queue)— wrapped at the callback, the OUTERMOST point, so the ack and the payload decode happen inside the traced request.The publish injects
traceparent(a child, so the consumer hangs beneath the publish rather than claiming to be it),tracestate,baggageand anx-chronos-enqueued-atstamp into the AMQP application headers, and records a producer span; each delivery becomes its ownQUEUE-rooted request carryingmessaging.message.queue_time_ms,messaging.message.redeliveredand the normalised destination. A publish to a named topic exchange carries NOmessaging.destination.name: a topic has no queue, and which queues are bound to that routing key is not knowable from the publisher — such a span is named after the exchange instead, which is the same string the consumer's binding names. Like the Laravel queue bridge this needs noCHRONOS_PHP_CLI_ENABLED, and leaving it off is better for the same reason.bunny/bunnystays a composersuggest: the bridge only declares for an application that already has it. -
Symfony — a Flex recipe ships in
recipe/so a Symfony Flex application picks upChronosBundleautomatically the momentcomposer requireruns, the same zero-touch contract Laravel gets from package auto-discovery. An application not on Flex (or pointed at an index this private package is not published to) needs the one line the recipe would otherwise have added — seerecipe/README.mdfor the exact line and why Flex alone cannot reach a private package without it. Once registered — by either path — cache pools are wrapped so reads show hit/miss and the unserialized hit value, Messenger/HttpClient/DBAL/Monolog wire themselves via one compiler pass, and the kernel is decorated automatically (ChronosHttpKernelis available to decorate by hand instead, for an application that does not use the bundle). -
symfony1 — register
Chronos\Collector\Framework\Symfony1\ChronosFilterinfilters.yml. -
Custom spans —
$chronos->span->create('name'), or declare an instrumentation manifest ofChronos\trace_method('Class', 'method')calls and pointinstrumentation_manifestat it — no application code changes. -
Inline query plans —
EXPLAINcapture on the real statement with the real binds (CHRONOS_PHP_EXPLAIN=1).
4. Connect to the Chronos stack
There is nothing to configure. The local Chronos Docker stack owns a shared
volume named chronos-spool and runs a collector that automatically drains
every file written into it — the spool file itself carries the
org/project/application identity, so one collector serves every service on the
machine. Your app just mounts the volume where spool_directory points:
services: my-app: volumes: - "chronos-spool:/var/lib/chronos/spool" volumes: chronos-spool: external: true # created and drained by the Chronos stack
Write telemetry to the path; it appears in Chronos. No ingest URL, token, or certificate ever enters your application's configuration.
(Deploying an app somewhere the stack's volume can't reach — another host, a
remote environment? Run your own chronos-engine-agent next to the app pointed
at its spool directory; see engine/deploy/php-spool-forwarder.md.)
Cost guarantees
- Extension installed,
enabledexplicitly0: requests start nothing, the Zend observer is never registered, the package's framework hooks register nothing. One cached config check per process. Measured on PHP 8.3: a function-call-bound workload runs at the same speed as with no extension installed at all. - Extension installed,
enabledUNSET: everything above holds except the observer, which IS registered — and costs roughly 30 ns on every userland function call whether or not anything is being collected (measured ~19 ns/call without the extension, ~50 ns/call with it).zend_observer_fcall_registeris MINIT-only, so the decision cannot be deferred to the first request; an absent setting has to assume a later request might turn collection on. Write the0and the cost goes away. On a request making 100k calls the difference is ~3 ms; at 1M calls, ~30 ms. - Package installed, extension absent: every call is
extension_loaded/function_exists-guarded and fail-open — silent no-ops, nothing registered. - Enabled, request not head-sampled (
apm_sample_rate< 1): the sample decision is made in Rust before any capture; unsampled requests skip HTTP capture and response-body copies entirely. - Enabled and sampled, but not profile-sampled (
profile_request_rate< 1): no timer is armed and no stacks are walked. Profiling has its own rate — it used to ride the APM decision, so profiling a percent of traffic meant discarding most of your traces with it. - Counted profile (
profile_deterministic, on by default): this is the one tier that does work on an unsampled request, so it is worth being precise. It adds no new pass over your code — the Zend observer already ran begin/end trampolines on every userland call and discarded the result. Enabling it also removes two heap allocations the observer previously paid per call (the function name is now interned perzend_functioninstead of being rebuilt every time), and adds a clock-read pair and one hashmap upsert. The net per-call effect is a design intent, not a benchmarked guarantee — measure it on your workload before relying on a number. Memory is bounded by DISTINCT functions (profile_deterministic_max_functions), never by call volume, and internal builtins are still untouched.profile_deterministic=0opts out. - CLI / workers: not auto-collected unless
cli_enabled=1; a queue worker integration can still start/end request scopes explicitly.
Settings reference
Every setting has one canonical env name. The other two spellings derive from it
mechanically: strip the CHRONOS_PHP_ / CHRONOS_ prefix and lowercase for the
.chronos key, prefix with chronos. for php.ini. So
CHRONOS_PHP_APM_ENABLED is apm_enabled in a .chronos file and
chronos.apm_enabled in php.ini. Precedence: env > php.ini > .chronos.
This table is COMPLETE — every name in the collector's settings::SETTING_NAMES
appears below, and scripts/verify-collector-settings-docs.sh fails the build if
one is added without a row here.
Identity — required; without these the collector stays inert
.chronos key |
env | default |
|---|---|---|
enabled |
CHRONOS_PHP_ENABLED |
0 — the master switch |
organisation |
CHRONOS_PHP_ORGANISATION |
— (required) |
team_id |
CHRONOS_PHP_TEAM_ID |
— the team owning this service; overrides project |
project |
CHRONOS_PHP_PROJECT |
— (required, unless team_id is set) |
application |
CHRONOS_PHP_APPLICATION |
— (required) |
spool_directory |
CHRONOS_PHP_SPOOL_DIRECTORY |
— (required) |
A team and a project are the same thing under two names. team_id is the
current spelling: a service declares its owning team by writing it into its own
.chronos, and the estate allocates the service to that team on sight. Setting
both names is not a conflict — team_id is taken and project ignored.
Deploy identity — resolved from .git when unset
.chronos key |
env | default |
|---|---|---|
app_version |
CHRONOS_APP_VERSION |
nearest git tag |
app_commit |
CHRONOS_APP_COMMIT |
git HEAD |
app_branch |
CHRONOS_APP_BRANCH |
git branch |
app_language_version |
CHRONOS_PHP_APP_LANGUAGE_VERSION |
PHP_VERSION, reported by the SDK bridge |
app_framework |
CHRONOS_PHP_APP_FRAMEWORK |
reported by the SDK bridge |
app_framework_version |
CHRONOS_PHP_APP_FRAMEWORK_VERSION |
reported by the SDK bridge |
Capture tiers
.chronos key |
env | default |
|---|---|---|
apm_enabled |
CHRONOS_PHP_APM_ENABLED |
0 |
logs_enabled |
CHRONOS_PHP_LOGS_ENABLED |
0 |
profiler_enabled |
CHRONOS_PHP_PROFILER_ENABLED |
0 |
cli_enabled |
CHRONOS_PHP_CLI_ENABLED |
0 — CLI/workers are not auto-collected |
dst_enabled |
CHRONOS_PHP_DST_ENABLED |
0 — lab/CLI only; ignored when env is production/prod, where the only path is the x-chronos-dst header / chronos_dst cookie |
env |
CHRONOS_PHP_ENV |
— (production/prod refuses process-wide DST) |
propagation_priority |
CHRONOS_PHP_PROPAGATION_PRIORITY |
1 — see Sharing a process with another tracer below |
Sharing a process with another tracer
Where Chronos is instrumenting, Chronos owns the outbound traceparent. A
process running a second tracer — ddtrace, typically, during a migration — hooks
curl_exec too, and injects from inside the function handler, i.e. AFTER our
observer hook. Its header would be the last writer, so the callee joins ITS trace
while the client span we recorded sits under ours: caller and callee land in two
different trace ids, and every read path that joins a trace (the dependency
graph, hop banding, the waterfall) sees two disconnected halves.
Before the first outbound call of a request, the extension therefore turns the
other tracer's distributed-tracing injection off for the remainder of that
request (datadog.distributed_tracing, a PHP_INI_ALL setting, so Zend restores
it at request shutdown — nothing is written to any configuration file). Only
propagation is affected: the other tracer's spans, agent and sampling are
untouched.
Set CHRONOS_PHP_PROPAGATION_PRIORITY=false to leave the wire to the other
tracer. Chronos keeps recording its own spans, and the service map falls back to
the first-party chronos.peer.application stamp the Engine writes at ingest — so
the edges still appear, without the joined callee spans underneath them.
There is no runtime-metrics tier. The extension used to write one .metrics
spool file per request behind CHRONOS_PHP_RUNTIME_METRICS_ENABLED, carrying a
request count, its duration and (on Linux) VmRSS. Every one of those numbers
is already on the request's root span, which the engine now reads for traffic,
latency and served routes — so the tier bought a second fsync() on the
request's own critical path and nothing else. The three tiers above are the
whole surface.
Sample rates
All rates are FRACTIONS: 1 is all of it, 0.1 a tenth, 0 none. They are
quantised to sample_rate_denominator, so at the default of 1000 the finest rate
you can express is 0.001 — one in a thousand. A rate that rounds to zero is
reported on the collector's startup line rather than silently sampling nothing.
The older spelling was basis points (apm_sample_rate=10000 for 100%). Since a
fraction can never exceed 1, any value above 1 is still read as basis points,
so existing files keep working and the startup line names which settings were
read that way. The one ambiguous value is exactly 1: basis points meant 0.01%,
a fraction means 100%, and it is read as the fraction.
.chronos key |
env | default |
|---|---|---|
apm_sample_rate |
CHRONOS_PHP_APM_SAMPLE_RATE |
1 — trace every locally-rooted request |
profile_request_rate |
CHRONOS_PHP_PROFILE_REQUEST_RATE |
0 — web requests are not profiled unless asked |
profile_job_rate |
CHRONOS_PHP_PROFILE_JOB_RATE |
0.1 — a tenth of background jobs |
sample_rate_denominator |
CHRONOS_PHP_SAMPLE_RATE_DENOMINATOR |
1000 (clamped to 10..1,000,000) |
profile_token |
CHRONOS_PHP_PROFILE_TOKEN |
— empty disables the forced-profile directive entirely |
An inbound traceparent always wins: only a locally-rooted trace makes its own
APM decision, so apm_sample_rate does not apply to a request that arrived with
one. A forced profile is the single exception — it is an explicit human
instruction, so it keeps its trace even against the caller's decision.
Profiling on demand
With profile_token set, one request is profiled whatever the rate says:
curl -H "X-Chronos-Profile: $TOKEN" https://your-service/checkout
…or a whole browser session, via a chronos_profile=$TOKEN cookie. The token is
mandatory and compared in constant time: a header that makes the server do
materially more work must never be armable by an unauthenticated caller.
A forced profile also forces its trace to be kept — a profile is reached
through its request, so one without a sampled trace is an orphan. Profiles
carry trigger=forced or trigger=sampled, and job profiles carry
workload=job, so one deliberate capture is never mistaken for a representative
sample and the two populations can be separated when aggregating.
Which requests count as background jobs
A request is a job when its method is not one of the nine HTTP verbs — the
Laravel queue bridge reports QUEUE, a native CLI start reports nothing at all.
It is a positive list of web verbs on purpose: a future CRON/CONSUME bridge
lands on the job rate automatically, whereas a list of job words would silently
fall through to the web rate of zero and profile nothing.
The job rate only bites where jobs are ALREADY traced: native CLI needs
cli_enabled=1, and a framework worker has to open a job through the SDK bridge.
A service that never instrumented its workers profiles nothing.
Profiler shape
.chronos key |
env | default |
|---|---|---|
profile_sample_rate |
CHRONOS_PHP_PROFILE_SAMPLE_RATE |
99 Hz — the stack-walk frequency, NOT a fraction of requests. 0 disables the sampler |
profile_types |
CHRONOS_PHP_PROFILE_TYPES |
all four: cpu, wall, off_cpu, io |
profile_series_id |
CHRONOS_PHP_PROFILE_SERIES_ID |
php |
profile_io_min_us |
CHRONOS_PHP_PROFILE_IO_MIN_US |
1000 — I/O waits shorter than this are not worth a stack walk |
profile_sample_rate (how densely a profiled request is sampled) and
profile_request_rate (how many requests get profiled) are easy to confuse. The
names are close because the second one arrived later; read the units.
Counted profile (deterministic)
Exact per-function call counts and inclusive/exclusive time, from the Zend observer rather than the sampler. Three tiers, and only the first is on by default.
.chronos key |
env | default |
|---|---|---|
profile_deterministic |
CHRONOS_PHP_PROFILE_DETERMINISTIC |
1 — on by default, unlike every other capture tier. See the cost note below |
profile_deterministic_max_functions |
CHRONOS_PHP_PROFILE_DETERMINISTIC_MAX_FUNCTIONS |
4096 distinct functions per request |
profile_edges |
CHRONOS_PHP_PROFILE_EDGES |
0 — tier 2, caller/callee edges |
profile_edges_max |
CHRONOS_PHP_PROFILE_EDGES_MAX |
8192 distinct edges per request |
profile_args |
CHRONOS_PHP_PROFILE_ARGS |
0 — tier 3, argument capture. Needs a forced profile or armed DST as well as this flag |
profile_args_max_args |
CHRONOS_PHP_PROFILE_ARGS_MAX_ARGS |
8 arguments per call |
profile_args_max_arg_bytes |
CHRONOS_PHP_PROFILE_ARGS_MAX_ARG_BYTES |
256 bytes per argument |
profile_args_max_total_bytes |
CHRONOS_PHP_PROFILE_ARGS_MAX_TOTAL_BYTES |
4096 bytes per request |
profile_args_max_invocations |
CHRONOS_PHP_PROFILE_ARGS_MAX_INVOCATIONS |
4 retained invocations per function |
Tier 1 — counts and timing (default on). Every observed userland function
gets a call count, an inclusive time and an exclusive time. Recursion is banked
once on the outermost frame, so a recursive function's inclusive time is not
multiplied by its depth. Internal builtins (strpos, array_map, …) are NOT
counted — they carry no observer trampoline, and attaching one to them is where
the real cost would be.
Tier 2 — edges (opt in). Caller-to-callee totals, which is what makes per-call-path attribution possible. Without it, a tier 1 row sums every path through a function.
Tier 3 — arguments (opt in, and gated twice). Bounded, redacted, scalar-only
argument values, and ONLY for functions the instrumentation manifest
allowlisted via Chronos\trace_method. It stays inert unless profile_args=1
and the request carries a forced-profile directive or DST recording is armed.
Composite values (arrays, objects, resources) are never captured — only their
type is recorded. Redaction uses the same key patterns as redact_patterns.
This is deliberately not a general parameter recorder: capturing every argument
on every call would make the collector a PII firehose, which is why it is
allowlist-scoped rather than rate-scoped.
Every cap reports rather than silently drops: when a request exceeds one, the spooled document carries the seen/kept counts, so a truncated profile is visibly truncated instead of quietly wrong.
Numbers from this tier are exact, but they are scoped to the FUNCTION, not to one call path — a figure shown against a flame-graph frame covers every path through that function, and reads higher than the frame's own subtree. Tier 2 is what narrows it to a path.
Span detail
.chronos key |
env | default |
|---|---|---|
span_all_userland |
CHRONOS_PHP_SPAN_ALL_USERLAND |
0 |
span_min_duration_us |
CHRONOS_PHP_SPAN_MIN_DURATION_US |
100 |
exclude_paths |
CHRONOS_PHP_EXCLUDE_PATHS |
/vendor/,/node_modules/,/cache/,/.git/ |
instrumentation_manifest |
CHRONOS_PHP_INSTRUMENTATION_MANIFEST |
— |
local_rich_telemetry |
CHRONOS_PHP_LOCAL_RICH_TELEMETRY |
0 |
HTTP capture
.chronos key |
env | default |
|---|---|---|
http_capture |
CHRONOS_PHP_HTTP_CAPTURE |
1 |
http_capture_bodies |
CHRONOS_PHP_HTTP_CAPTURE_BODIES |
1 |
http_capture_max_body |
CHRONOS_PHP_HTTP_CAPTURE_MAX_BODY |
65536 (64 KiB) |
http_capture_response_buffer |
CHRONOS_PHP_HTTP_CAPTURE_RESPONSE_BUFFER |
0 |
http_capture_redact |
CHRONOS_PHP_HTTP_CAPTURE_REDACT |
1 |
redact_patterns |
CHRONOS_PHP_REDACT_PATTERNS |
authorization, password, credential, private_key, client_secret, access_token, refresh_token, secret, api_key — matched case-insensitively against the KEY |
Messaging capture
.chronos key |
env | default |
|---|---|---|
messaging_capture_bodies |
CHRONOS_PHP_MESSAGING_CAPTURE_BODIES |
1 |
messaging_capture_max_body |
CHRONOS_PHP_MESSAGING_CAPTURE_MAX_BODY |
65536 (64 KiB) |
Note the default: this is ON, same as http_capture_bodies. A messaging span
that only records a byte count cannot answer what crossed the broker. Set
CHRONOS_PHP_MESSAGING_CAPTURE_BODIES=0 on a service that must not copy
payloads into telemetry.
No field-level masking applies to a body on either path. redact_patterns
masks map ENTRIES — header and query-parameter keys — and a body is only ever
truncated. A protobuf payload has no field names on the wire to match against at
all. So this flag is the whole control: with it on, complete payloads ship, PII
included.
The configured size is a ceiling, not the effective limit. A publish body rides a
span attribute capped at 16 KiB (12 KiB of raw bytes once base64 inflates a binary
payload); a consume body rides the request-attribute bag, capped at 8 KiB (6 KiB
raw). The SDK truncates to whichever is smaller and sets
messaging.message.body.truncated, so a cut body always says it was cut.
Non-UTF-8 payloads — which is every protobuf — are base64-encoded and marked with
messaging.message.body.encoding, because the value has to cross into a Rust
String and then through serde_json, and both require valid UTF-8.
DST and spool
.chronos key |
env | default |
|---|---|---|
dst_call_path_max |
CHRONOS_PHP_DST_CALL_PATH_MAX |
4096 — DST-gated first-party call visits |
dst_call_path_max_depth |
CHRONOS_PHP_DST_CALL_PATH_MAX_DEPTH |
64 |
spool_max_bytes |
CHRONOS_PHP_SPOOL_MAX_BYTES |
921600 (900 KiB) per spooled document |
Query plans (PHP package only)
These live in the Composer package rather than the extension, so they are read
from the PHP environment (getenv, $_ENV, $_SERVER) and — when the extension
is loaded — from a .chronos file. Not from php.ini: an unregistered
chronos.* INI directive cannot be set, and these are not in the extension's
registration list. Both name spellings are accepted; LOCAL_ is the family the
other development-only switches use.
| env (either spelling) | default |
|---|---|
CHRONOS_PHP_EXPLAIN / CHRONOS_PHP_LOCAL_EXPLAIN |
off — EXPLAIN the real statement with its real binds |
CHRONOS_PHP_EXPLAIN_WRITES / CHRONOS_PHP_LOCAL_EXPLAIN_WRITES |
off — also explain INSERT/UPDATE/DELETE/REPLACE |
CHRONOS_PHP_EXPLAIN_MAX_PER_REQUEST / CHRONOS_PHP_LOCAL_EXPLAIN_MAX_PER_REQUEST |
5 (clamped to 1..50) |
Reading a setting from PHP: chronos_setting('apm_sample_rate') accepts any name
in the tables above, resolved through the same precedence chain.
Development
php api/tests/verify.php # package test suite (spec-conformance sections # auto-skip outside the platform monorepo)