misakstvanu / prism
Prism client — captures Laravel telemetry (logs, requests, errors, traces, queries, jobs, crons, replica metrics) and ships it to a Prism observability workspace.
Requires
- php: ^8.4
- illuminate/support: ^12.0|^13.0
- keepsuit/laravel-opentelemetry: ^2.2
- laravel/nightwatch: ^1.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
This package is auto-updated.
Last update: 2026-08-24 13:34:01 UTC
README
The client package for the Prism observability platform. Drop it into any Laravel 12+ application to capture logs, requests, errors, traces, queries, jobs, scheduled tasks and replica metrics and ship them, correlated by trace, to a Prism workspace.
Everything is captured automatically once two variables are set — no code changes, no manual instrumentation required. The package deliberately depends on nothing in the Prism server application, so it installs cleanly into a stranger's project.
Requirements
- PHP 8.4+
- Laravel 12 or 13 (
illuminate/support^12.0|^13.0) - A Prism workspace and an ingest API token (Console → Settings → API tokens)
Two packages come along as dependencies, and neither needs an OS or PECL extension — the whole
install is composer require and two variables:
laravel/nightwatch(^1.0) is the capture engine. Its agent daemon is not used and not required: Prism installs its own transport over Nightwatch's ingest seam, so records never leave the process except in a Prism batch. There is no phar to run, no socket to open and no Laravel Nightwatch account to hold.keepsuit/laravel-opentelemetry(^2.2) is the span lane, the half that gives the Traces waterfall its parent/child nesting. It instruments the framework through events and middleware (unlikeopen-telemetry/opentelemetry-auto-laravel, which hard-requires theext-opentelemetryC extension). Nothing is exported as OTLP: Prism consumes the spans in process, so there is no collector to run and no exporter connection is ever opened.
Quickstart
Three steps and telemetry starts flowing.
1. Install
composer require misakstvanu/prism
The service provider (Misakstvanu\Prism\PrismServiceProvider) is auto-discovered — there is no
provider to register and no kernel file to edit.
2. Set the two required variables
PRISM_TOKEN=prism_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PRISM_APP=your-app-slug PRISM_ENDPOINT=https://your-workspace.prism.app/api/ingest
PRISM_TOKEN and PRISM_APP are the only required settings. PRISM_ENDPOINT defaults to the
hosted endpoint; set it to your workspace's /api/ingest if you self-host. Everything else has a
working default.
3. Verify
php artisan prism:check
This validates the config and POSTs a single test event to confirm the endpoint accepts your token. A green run means the next request, job or scheduled task your app runs is already reporting to the console.
It also reports the capture engine, so the two questions Nightwatch's own documentation raises are answered on the spot:
Capture engine
engine ......... laravel/nightwatch v1.28.7
ingest ......... in-process — records go straight into Prism's buffer
agent .......... not required — no daemon, nothing opens the agent socket
otel spans ..... configured
token .......... present
endpoint ....... https://your-workspace.prism.app/api/ingest
ingest is the line to read when a workspace looks empty: it says whether Prism's own pipeline is
installed over the engine's, which happens only when PRISM_ENABLED is true and PRISM_TOKEN
is set. otel spans is the second half of the same question — it says whether the span processor
that gives the Traces waterfall its nesting is registered; not configured costs the nesting and
nothing else. Anything else there means records are being captured and handed to a socket ingest nothing
is listening on — which looks exactly like a quiet application from the console.
Nothing else is needed. Requests, exceptions, logs, queries, commands, mail, notifications, jobs and scheduled tasks are the capture engine's, the trace waterfall is the span lane's, and replica metrics are Prism's own — all of it wired by the provider at boot. In particular you do not have to add the engine's
nightwatchchannel toconfig/logging.php: Prism attaches its handler to the channelslog.channelsnames for you.
Required variables
| Variable | Purpose |
|---|---|
PRISM_TOKEN |
The ingest token minted in Console → Settings → API tokens. With no token the package silently no-ops and logs a single warning at boot — it never throws. |
PRISM_APP |
The application slug this process reports as, inside the workspace the token belongs to. |
Configuration
Publish the config to override any default:
php artisan vendor:publish --tag=prism-config
The published config/prism.php documents every setting inline. Every value can also be driven by
an environment variable, so most installs never publish the file.
One config file, three subjects. Prism owns the capture engine's and the span lane's
configuration as well as its own: nightwatch.* and opentelemetry.* are derived from the
prism.* keys below at boot, so there is nothing else to publish and nothing else to keep in step.
Publish either of those files yourself and Prism steps back from it entirely — see
Capture engine and
Span lane for what that costs.
The tables in this section are a hand-maintained mirror of
config/prism.php. Nothing generates one from the other, so a change to the config file that does not edit this README in the same commit leaves the two disagreeing — with the README being what an installer reads.
Master switch and credentials
| Key | Env | Default | Meaning |
|---|---|---|---|
enabled |
PRISM_ENABLED |
true |
The kill switch. false registers no listeners and adds zero overhead — the package is inert, as if uninstalled. |
token |
PRISM_TOKEN |
null |
Ingest token. (required) |
app |
PRISM_APP |
null |
Application slug this process reports as. (required) |
endpoint |
PRISM_ENDPOINT |
https://prism.dev/api/ingest |
Where batches are POSTed. Point at your workspace's /api/ingest when self-hosting. |
Identity
| Key | Env | Default | Meaning |
|---|---|---|---|
environment |
PRISM_ENVIRONMENT |
APP_ENV |
Deployment tier (production, staging, …) tagged onto every event. |
replica |
PRISM_REPLICA |
gethostname() |
The name this process reports as. One replica row auto-registers per name; override with the pod/container name where the hostname is not meaningful. |
Capture engine (laravel/nightwatch)
Which signals are captured is Nightwatch's vocabulary — this package declares no capture toggles
of its own. Prism derives Nightwatch's config from its own at register time, so you publish one
config file, not two:
| Nightwatch key | Derived from | Why |
|---|---|---|
nightwatch.enabled |
prism.enabled |
One master switch. Nightwatch defaults itself to enabled, so without this PRISM_ENABLED=false would leave 38 framework hooks registered. |
nightwatch.token |
prism.token |
The two should name the same credential. |
nightwatch.server |
prism.replica |
A record's own server field and the batch envelope's replica field must agree, or one process reports under two names. Re-derived once more after every provider has booted, so a host that names its process from its own boot() still gets one name on both. |
nightwatch.sampling.requests |
prism.sample.requests |
See Sampling. |
nightwatch.sampling.commands |
prism.sample.commands |
" |
nightwatch.sampling.scheduled_tasks |
prism.sample.schedules |
" |
nightwatch.sampling.exceptions |
pinned at 1.0 |
An error is never sampled out. This is the one derived key with no environment escape hatch. |
nightwatch.capture_request_payload |
prism.request.capture_payload |
See Scrubbed keys. |
nightwatch.redact_payload_fields |
prism.scrub |
" (upstream's own defaults survive underneath) |
nightwatch.redact_headers |
prism.scrub |
" |
nightwatch.ingest.uri is left at its default and never used — nothing opens that socket.
Taking a key back is deliberately not "set it in the published config and Prism will notice". Nightwatch registers before Prism in every real install and merges its own defaults first, so by the time Prism looks, every key exists and "is it unset" answers nothing. Ownership is decided by evidence that survives that ordering, and there are two kinds:
- Publish
config/nightwatch.php(php artisan vendor:publish --tag=nightwatch-config) and Prism writes nothing at all — the whole file is yours, including the keys above. - Set the matching
NIGHTWATCH_*variable and that one key is yours, the rest stay derived:NIGHTWATCH_ENABLED,NIGHTWATCH_TOKEN,NIGHTWATCH_SERVER,NIGHTWATCH_REQUEST_SAMPLE_RATE,NIGHTWATCH_COMMAND_SAMPLE_RATE,NIGHTWATCH_SCHEDULED_TASK_SAMPLE_RATE,NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD,NIGHTWATCH_REDACT_PAYLOAD_FIELDS,NIGHTWATCH_REDACT_HEADERS.nightwatch.sampling.exceptionshas no variable on purpose.
Sampling
The share of executions captured, as a fraction of 1. 1.0 keeps everything (the default), 0.0
keeps nothing.
| Key | Env | Default | Meaning |
|---|---|---|---|
sample.requests |
PRISM_SAMPLE_REQUESTS |
1.0 |
Fraction of HTTP requests captured. |
sample.commands |
PRISM_SAMPLE_COMMANDS |
1.0 |
Fraction of artisan commands captured. |
sample.schedules |
PRISM_SAMPLE_SCHEDULES |
1.0 |
Fraction of scheduled-task runs captured. |
The unit is an execution, not a signal — so lowering the request rate lowers span volume with
it. A request that is sampled out contributes nothing at all: not its own row, and not the
queries, cache reads, log lines, outgoing calls or spans it made along the way. There is no separate
span, query or log rate to turn down, and no half-captured trace to reassemble at the far end. Halve
PRISM_SAMPLE_REQUESTS and you halve everything a request produces.
A job inherits the decision made about whatever dispatched it. There is no job rate for the same
reason there is no query rate: a worker's run of a job carries the dispatching execution's verdict in
Laravel's Context, into the payload and out the other side. So a request kept by sample.requests
has the jobs it queued kept with it, and a request that was dropped drops them — the trace stays
whole across the queue boundary rather than half-recorded.
An error is never lost to sampling. There is deliberately no sample.exceptions: an execution
that was sampled out is pulled back into the sample the moment it throws, so the fault — and the
context buffered before it — ships anyway. The Prism server pins the exception domain to 1.0 on
ingest as well, so a team is never blind to breakage because of a rate someone tuned.
Sampling composes with the server-side rules configured per application in Console →
Settings → Sampling, which decide what a workspace keeps once a batch arrives. The two multiply
and neither knows about the other: a client at 0.5 under a workspace rule at 50% keeps a
quarter. Client-side sampling is the one that saves bandwidth; server-side is the one that saves
storage and quota.
A rate this package cannot read at all is treated as 1.0, never 0.0 — capturing more than
intended shows up on a bill, while capturing nothing shows up as a console that has quietly been
empty for a week.
Note the key is
sample, notsampling. On a host that is a Prism workspace,prism.samplingis the server's own per-workspace block and the two must not collide: a package/host config merge is shallow, so one key cannot mean two things.
Span lane (keepsuit/laravel-opentelemetry)
| Key | Env | Default | Meaning |
|---|---|---|---|
otel.enabled |
PRISM_OTEL_ENABLED |
true |
The span lane. false switches the OpenTelemetry SDK off outright — no instrumentation is registered and no span is produced. |
otel.slow_trace_ms |
PRISM_SLOW_TRACE_MS |
2000 |
A trace at or beyond this many milliseconds is kept whole by tail sampling, however aggressive sampling is. An unreadable value falls back to 2000, never to zero. |
The lane exists for one thing the capture engine cannot express: nesting. Nightwatch's cache, query and outgoing-request sensors fire on completion only, so their records are flat siblings sharing a trace id. An OpenTelemetry span carries a parent span id by construction, which is what the Traces waterfall is drawn from.
It is also where the trace id comes from. Two engines mint one: the capture engine a UUID per execution, the SDK a 32-hex W3C id per trace — and every Prism screen correlates a request with the spans, logs, queries, exceptions and jobs it produced by that single column. OpenTelemetry wins, because its id is the one that travels. Every record is re-keyed onto it as it passes through Prism's ingest, so nothing you do is needed — and a signal recorded with no OpenTelemetry trace to speak of keeps the id it arrived with, so nothing is ever left unkeyed.
And it is what carries a trace between processes. propagators is left at tracecontext, so a
trace crosses three boundaries as the W3C standard every APM and every language SDK speaks:
- an incoming
traceparentcontinues the caller's trace, so a request arriving from another service is the same trace rather than an adjacent one; - an outgoing call through Laravel's HTTP client has
traceparentinjected, so the service you call continues yours — whatever it is written in; - a dispatched job carries
traceparentin its payload and runs under a CONSUMER span parented to the PRODUCER span that queued it, so a worker's records land under the request that queued them.
Nothing bespoke rides along: traceparent is spoken by every APM and every language SDK, so a
Laravel app calling a Go service produces one trace rather than two. A trace id is therefore a
32-character lowercase hex string, not a UUID — worth knowing if you match or store one of your
own. If you propagate the header by hand from a service Prism does not instrument, its format is
00-<32 hex trace id>-<16 hex span id>-<2 hex flags>.
With the lane on, it is also the producer of two signals the capture engine watches as well.
Both engines see a query and an outgoing HTTP call, so leaving both alone would store one query as a
queries row and a db span while storing one outgoing call as two http spans. The span wins,
because it is the one that knows its parent — so the engine's own query and outgoing-request
records are dropped as they pass through Prism's ingest, and the queries row is built from the db
span instead (db.query.text, db.system.name, the span's duration, and Prism's own slow marker
from query.slow_threshold_ms). Three consequences worth knowing:
- A record is dropped only when the span really replaced it. A console command opens no trace
(the console instrumentation is off — the capture engine owns commands), and neither does a query
run before the request span opens or a process where you published
config/opentelemetry.phpand did not add Prism's processor. In every one of those the engine's record is kept, because it is the only producer there is. - The SQL is the span's
db.query.text, which OpenTelemetry truncates at 500 characters, andconnectionis the driver (pgsql,mysql) rather than Laravel's connection name — no span attribute names the connection. A statement upstream's query instrumentation skips (it recordsSELECT,INSERT,UPDATEandDELETEonly, so notbegin,commitor DDL) has no span and so no row. SetPRISM_OTEL_ENABLED=falseif you would rather have the engine's fuller records without the nesting. - A Redis command is a
dbspan and never aqueriesrow. Both instrumentations name the system the same way, which is why one lane rule covers both — but Prism's query signal is Laravel'sQueryExecuted, which Redis does not raise.
prism.scrub applies to the span lane too: the statement and the outgoing URL meet the same list the
engine's records do, so a scrub entry cannot cover one producer out of two.
A slow or failing request's trace is kept whole. The lane runs with OpenTelemetry tail
sampling on, which defers the keep-or-drop decision until the trace has finished and then applies two
rules: any span whose status is ERROR keeps the whole trace, and any trace at or beyond
otel.slow_trace_ms (PRISM_SLOW_TRACE_MS, 2000 by default) keeps the whole trace. This is the
client-side half of a rule the Prism server already enforces on what it receives — belt and braces
rather than a duplicate: the server's half keeps a slow trace once it has arrived, this one keeps it
from being dropped before it is sent at all. Answer any of upstream's own
OTEL_TRACES_TAIL_SAMPLING_* variables and your answer is kept, and a tail-sampling rule of your own
is merged beside Prism's rather than replaced.
Because tail sampling holds a trace's spans until its decision — decision_wait is 5000ms by
default, longer than most requests live — Prism force-flushes the tracer immediately before
draining its own buffer, so whatever is released lands in the batch its own execution ships rather
than in whichever execution flushes next. Nothing about that needs configuring, and under the spool
flush strategy a span released after its request has ended is carried by the next drain.
Two things follow that are worth knowing. Turning tail sampling on makes OpenTelemetry's head
sampler always-on and applies whatever you configured (OTEL_TRACES_SAMPLER_TYPE=traceidratio, say)
at the tail instead — after the errors and slow-trace rules have had their say, which is the point.
And Prism's own span processor sits beside that decision rather than behind it, so what Prism
ships is not thinned by the OpenTelemetry sampler at all: the knob for span volume is
sample.requests, because a sampled-out execution discards its whole buffer, spans included.
Turning it off costs the nesting, the shared id and cross-process propagation, and nothing else —
requests, errors, logs, queries, jobs, commands, mail, notifications and replica metrics all keep
flowing, correlated within one execution by the capture engine's own trace id, the Traces screen
falls back to the flat spans its records produce, and the queries rows go back to being the
engine's. What does not survive is a trace that spans services or a queue: there is no traceparent
to carry, so each process traces itself. PRISM_ENABLED=false switches it off along
with everything else.
As with the capture engine, Prism derives the whole OpenTelemetry configuration from its own, so you publish one config file, not three:
| OpenTelemetry key | Derived from | Why |
|---|---|---|
opentelemetry.disabled |
prism.enabled + prism.otel.enabled |
One master switch. Also written into OTEL_SDK_DISABLED, because that variable — not the config key — is what Sdk::isDisabled() reads, and upstream stamped it before Prism could decide. |
opentelemetry.traces.exporter |
pinned at null |
Spans are consumed in process and ship in Prism's own batch. null is upstream's Noop exporter, so no PSR-18 client is discovered and no transport is ever built. |
opentelemetry.metrics.exporter |
pinned at null |
" |
opentelemetry.logs.exporter |
pinned at null |
" — logs are the capture engine's signal. |
opentelemetry.service_name |
prism.app |
A span's resource has to name the same application the batch envelope does. |
opentelemetry.service_instance_id |
prism.replica |
" (the replica) |
opentelemetry.resource_attributes |
prism.environment → deployment.environment |
" (the tier). Merged, so your own resource attributes survive alongside it. |
opentelemetry.worker_mode.flush_after_each_iteration |
pinned at true |
Upstream defaults it to false, which in a queue worker or an Octane process ships one job's spans inside the next job's batch — landing them under the wrong execution, silently. |
opentelemetry.traces.sampler.tail_sampling |
enabled, with ErrorsRule + SlowTraceRule from prism.otel.slow_trace_ms |
A slow or failing execution's trace is kept whole however aggressive sampling is. decision_wait stays at upstream's own default, because Prism force-flushes the tracer at the end of every execution. |
opentelemetry.traces.processors |
Prism's own span processor is appended | The whole span lane, in one line of config: Misakstvanu\Prism\Otel\PrismSpanProcessor turns each finished span into a Prism span event. Appended to upstream's own slot rather than replacing the tracer, so keepsuit keeps its resource, sampler and propagators — and your own processors keep working beside it. |
opentelemetry.propagators |
left at upstream's tracecontext |
A trace crosses a service or queue boundary as W3C traceparent. Prism writes nothing here; OTEL_PROPAGATORS is yours. |
opentelemetry.instrumentation.* |
see below | Five on, six off. |
On: HttpServerInstrumentation, HttpClientInstrumentation, QueryInstrumentation,
QueueInstrumentation, RedisInstrumentation — the lanes the waterfall draws.
Off: CacheInstrumentation (it only calls addEvent() on the active span, and a span event
is not a span — Prism has nowhere to store one, so the cache lane comes from the capture engine's
own record instead), ConsoleInstrumentation (the engine's command sensor already owns commands),
and EventInstrumentation / ViewInstrumentation / LivewireInstrumentation /
ScoutInstrumentation, which have no Prism counterpart at all.
Most of those keys have the environment variable upstream documents for them
(OTEL_TRACES_EXPORTER, OTEL_SERVICE_INSTANCE_ID, OTEL_INSTRUMENTATION_CACHE, …) and answering
one keeps your answer. OTEL_SERVICE_NAME and OTEL_SDK_DISABLED are the two exceptions, and
not for tidiness: the OpenTelemetry package writes both variables itself, during its own
register(), so by the time Prism looks they are always set and their presence says nothing about
what you asked for. The SDK switch still respects a true you resolved before Prism touched it; the
service name does not.
The way to take the whole thing over is to publish config/opentelemetry.php
(php artisan vendor:publish --tag=laravel-opentelemetry-config) — Prism writes nothing at all
when that file exists, on the assumption that a host with one is running the SDK on its own account.
Note that this also means Prism's span processor is not registered for you: add
Misakstvanu\Prism\Otel\PrismSpanProcessor::class to traces.processors yourself if you want the
waterfall as well as your own pipeline. php artisan prism:check reports that state as
otel spans ... not configured — … is installed but not registered.
Batching and flush
| Key | Env | Default | Meaning |
|---|---|---|---|
batch.size |
PRISM_BATCH_SIZE |
1000 |
Max events one request may buffer; the excess is dropped and counted, bounding memory. |
batch.flush |
PRISM_FLUSH_STRATEGY |
terminate |
terminate ships after the response is returned (off the critical path — the default). sync ships inline before the process ends (useful for one-off scripts and tests). spool parks the batch in the cache for one debounced drain job (see below). |
batch.timeout |
PRISM_FLUSH_TIMEOUT |
2.0 |
Ingest POST timeout in seconds, kept short so a slow endpoint never stalls the process. |
batch.queue_threshold |
PRISM_QUEUE_THRESHOLD |
100 |
A flush larger than this is handed to a queued job instead of sent inline. 0 keeps every flush inline. |
batch.queue |
PRISM_FLUSH_QUEUE |
null |
Queue the send/drain job is dispatched onto; null = default queue. |
The spool strategy
Under terminate and sync the process that captured the telemetry is also the one that ships it —
a POST per request. spool decouples the two: a finished batch is written to the cache and a single
debounced job drains everything spooled since the last drain, so a burst of requests costs one ingest
POST instead of one each. It is the right choice when reaching the ingest host is expensive per
request, and the necessary one when the host is the ingest host — an inline POST into a
single-worker dev server deadlocks against the request making it.
Only finished work is ever spooled (a flush runs at the end of a request, job or scheduled task), and a cache lock guards the hand-off, so a drain can never pick up a half-written batch. A batch that fails to send goes back on the spool and a fresh drain is armed, so an outage costs a delay rather than the telemetry.
It needs two things: a cache store with atomic locks shared by every process that captures (redis,
memcached, database, file — not array outside tests), and a queue worker (the drain never runs
on the sync driver). Without either, the flush quietly falls back to terminate rather than piling
up telemetry it cannot ship — php artisan prism:check reports which, and how many batches are
waiting.
| Key | Env | Default | Meaning |
|---|---|---|---|
batch.spool.delay |
PRISM_SPOOL_DELAY |
5 |
Seconds the drain waits before running — the debounce window everything spooled during it ships in. |
batch.spool.grace |
PRISM_SPOOL_GRACE |
60 |
Extra seconds the "a drain is pending" marker is held, covering the wait for a free worker. Once it lapses the next flush arms a replacement. |
batch.spool.ttl |
PRISM_SPOOL_TTL |
900 |
Seconds a spooled batch survives in the cache. |
batch.spool.max_batches |
PRISM_SPOOL_MAX_BATCHES |
500 |
Batches held at once; past the cap the oldest are dropped. 0 = unbounded. |
batch.spool.max_attempts |
PRISM_SPOOL_MAX_ATTEMPTS |
3 |
Send attempts a batch gets before it is discarded, so a wrong endpoint cannot cycle forever. |
batch.spool.store |
PRISM_SPOOL_STORE |
null |
Cache store backing the spool; null = default store. |
batch.spool.lock_seconds |
PRISM_SPOOL_LOCK_SECONDS |
5 |
How long the spool index lock is held. |
batch.spool.lock_wait |
PRISM_SPOOL_LOCK_WAIT |
3 |
How long a flush waits for that lock before giving up and sending inline. |
Per-domain limits
| Key | Env | Default | Meaning |
|---|---|---|---|
request.capture_payload |
PRISM_CAPTURE_REQUEST_PAYLOAD |
false |
Whether a request's body is recorded at all. Off, a body is captured only for a request that faulted. |
log.channels |
— | [] |
Logging channels Prism attaches the engine's handler to. Empty = the app's default channel/stack. You do not have to add nightwatch to config/logging.php yourself. |
log.level |
PRISM_LOG_LEVEL |
debug |
Minimum PSR-3 level captured. |
query.slow_threshold_ms |
PRISM_SLOW_QUERY_MS |
100 |
A query at/above this is marked slow, and is then kept (with its whole trace) whatever the server's per-workspace rules say. It does not survive the client-side rates above — those drop the execution before anything is sent. 0 disables the marker. |
Runtime metrics
| Key | Env | Default | Meaning |
|---|---|---|---|
metrics.system_interval |
PRISM_METRICS_INTERVAL |
60 |
Seconds between CPU/memory/uptime samples. Runs on every replica. |
metrics.queue_interval |
PRISM_QUEUE_POLL_INTERVAL |
30 |
Seconds between queue-depth/worker samples. Runs only on a process that works the queue. |
metrics.replica_type |
PRISM_REPLICA_TYPE |
null |
web or worker. null infers it from the process. |
Both samples are taken when an execution ends — a request, a command or a job — and the interval is
what keeps that from meaning a sample every time. A due sample ships in a batch of its own, and
deliberately so: the capture engine discards an execution's whole batch when the sampling
rates reject it, and a replica's health has nothing to do with whether one request was
interesting. Turning sample.requests down therefore does not thin out the Replicas screen or the
replica_cpu alert with it.
Runtime metrics are also the one signal the capture engine has no sensor for, so Prism keeps collecting them itself; everything else on this page is the engine's.
Ignore lists
Activity the client never captures. Every list but ignore.exceptions matches with Str::is
wildcards, so one pattern covers a family (api/internal/*, App\Jobs\Internal\*, prism:*); a
pattern without a * matches exactly.
| Key | Default | Meaning |
|---|---|---|
ignore.paths |
Prism's own routes, telescope*, horizon*, _debugbar*, nova*, up, health* |
Request URI patterns never captured. |
ignore.jobs |
[] |
Queued job class names, as resolved for display — for a queued broadcast that is the event class, not the framework's wrapper. Covers both the dispatch and the worker's run of it. |
ignore.commands |
[] |
Artisan command names — prism:*, reports:build. A scheduled task runs as one of these, so a schedule silenced here is silenced wherever it is started from. |
ignore.http |
[] |
Outgoing HTTP destinations, matched against the host, the host and path, and the full URL without its query string — redis.internal, *.googleapis.com and http://ch:8123/* all work. |
ignore.cache |
[] |
Cache keys, matched on the full key before it is truncated for display. |
ignore.exceptions |
NotFoundHttpException, ValidationException |
Exception classes never reported. Matched with instanceof, so subclasses are covered too. (Laravel's own "don't report" list already covers the two defaults; the entries earn their keep for anything you add.) |
paths, jobs and commands silence a whole execution: a request, a job's run or a command
that matches contributes nothing at all — not its own row and not the queries, cache reads, log
lines, spans or outgoing calls it made. http, cache and exceptions silence one signal
wherever it occurs, so an ignored cache key is invisible on an otherwise fully captured request.
The one signal that still leaves an ignored execution is the replica heartbeat
(replica_metric, see Runtime metrics). It is a fixed-cadence sample of the
process rather than a record of anything the execution did — the figure the Replicas screen and the
replica_cpu alert read — and it ships on its own so that a replica's health does not depend on
whether the request that happened to be running was one you wanted to see.
There are two reasons to add something here. The mild one is noise: a health check polled every second, or a cache key touched on every request, costs an event each time and tells you nothing.
The serious one is feedback. Capturing work that exists because of telemetry means the capture produces more work to capture. Prism excludes its own outbound batch automatically — it carries an internal marker header and runs under a suppression scope — and an inbound request carrying that same marker is treated the same way: capture is suppressed for its whole lifetime, so neither the request nor the queries, cache reads and log lines it triggers are recorded. An application that hosts the Prism workspace it reports to therefore never captures another client's ingest POST, on any endpoint, with no configuration. What it cannot know is the work your application does on Prism's behalf: the job that stores a batch, the counters it increments, the datastore it writes to. Those are what the lists above are for. Two cases worth checking in any install:
- A datastore reached over HTTP rather than as a database connection (ClickHouse, OpenSearch, a
cloud API) travels through Laravel's HTTP client, so every read and write becomes a span — and
storing that span is another write. Add its host to
ignore.http. - Cache is the chattiest signal. One operation touching half a dozen counters emits a span per
counter, so a prefix used for internal bookkeeping is worth an
ignore.cacheentry.
Scrubbed keys
Keys whose values are redacted at the source before an event leaves the process — matched case-insensitively. The list is additive; extend it with any field specific to your app. Defaults:
password, password_confirmation, secret, token, authorization, cookie, api_key,
access_token, refresh_token, credit_card, card_number, cvv, ssn.
One list governs every signal. A key here is redacted wherever it appears:
| Where | What happens |
|---|---|
| Request body, headers, query string | The value is replaced; the field, header and parameter stay, so the shape is still legible. |
| Outgoing call query string | Same, so a credential passed to a third-party API never reaches the console. |
| SQL statement | The value half of password = 'x' is replaced. A ? or :name placeholder is left alone. |
| Artisan command line | --password=x keeps the option and loses the value. |
| Cache key | token:abc becomes token:[REDACTED]; a key that is only a name (api_key) is untouched — the name of an entry is not a secret. |
| Mail subject, exception message | The value half of any name = value pair in the text. |
A queued job's payload is never captured at all, so a secret dispatched inside a job cannot reach the console however this list is written: nothing is recorded but the job's name, id, queue, connection and outcome.
Request bodies are held back twice over. The capture engine records a payload only for a request
whose response was a 500, and request.capture_payload (PRISM_CAPTURE_REQUEST_PAYLOAD)
defaults to false, which means no body is recorded at all. Set PRISM_CAPTURE_REQUEST_PAYLOAD=true
where the debugging is worth more than the exposure; the scrub list above still applies to whatever
is captured, and a request that succeeded has no body in the console at either setting.
Manual instrumentation
Automatic capture covers the common signals. Two static helpers on Misakstvanu\Prism\Prism let
you record things no instrumentation can see. Both are safe no-ops when the client is inert
(disabled or unconfigured), so you can call them unconditionally.
Report a handled exception — one your code caught but still wants in Prism (a swallowed
integration error, a best-effort background failure). It is recorded as handled, scrubbed and
buffered like any other:
use Misakstvanu\Prism\Prism; try { $gateway->charge($order); } catch (GatewayException $e) { report_to_ledger($order); Prism::captureException($e); }
Instrument a block of code as a trace span — arbitrary work no instrumentation sees, such as an
expensive computation or a third-party SDK call. The closure is timed and placed on the trace
waterfall as a real OpenTelemetry span, so anything opened inside it (a query, an outgoing call, a
nested Prism::span()) nests beneath it by construction and the trace it joins is the one that
crosses service and queue boundaries. The closure's return value is passed through, and with the
span lane off (PRISM_OTEL_ENABLED=false) the closure still runs — it is simply not recorded:
$report = Prism::span('generate monthly report', function () use ($account) { return $this->reporting->build($account); }); // Tint it as a database/http/… span with the optional type argument: $rows = Prism::span('warehouse rollup', fn () => $warehouse->rollup(), type: 'db');
Octane
The package is Octane-aware. Per-request state (the event buffer, the trace context and the recursion
guard) is reset on each RequestReceived, so one request's telemetry never
leaks into the next inside a long-lived worker. The terminate flush strategy fires under Octane's
per-request kernel terminate, and the HTTP transport reuses a keep-alive connection pool across
requests. No extra configuration is required.
Horizon
Queue-depth and worker-count metrics are read through Horizon automatically when it is installed —
the package detects Laravel\Horizon\Contracts\SupervisorRepository at runtime, with no
compile-time dependency, so nothing breaks when Horizon is absent. Job capture works the same way
whether you run queue:work, queue:listen or Horizon.
For queue metrics to be reported, run prism:check (or any job) on a worker process — queue
polling is skipped entirely on web replicas, and an idle worker with no jobs never reaches the end
of an execution, which is where the interval is consulted.
Capture cost
Two instrumentation engines listen on every request, so what capture costs one is measured rather than assumed:
php artisan prism:bench:capture
It times the same synthetic request under three configurations — nothing capturing, the capture
engine alone, and the capture engine plus the span lane — and reports what each one adds to the
request. Each configuration runs in a process of its own, because which engines listen is decided
during register(): three configurations in one process would be three names for one
configuration. The whole sweep repeats (--rounds, default 3) round-robin and each row reports its
median round, because ambient load moves the same measurement further than the difference being
looked for.
The command fails on one thing only: any sign that a span left the process as OTLP (a protobuf class loaded anywhere in a child). That is the claim a reader cannot check for themselves — there is no collector to run, nothing is exported — so it is the one thing gated. The cost figures are reported, not thresholded: what a captured operation costs depends on the workload, so the per-signal attribution below is the number to carry away and a limit on the headline would only ever describe the box that ran it.
The runtime metrics collectors are outside all of this — they sample on an interval rather than per request, and cost the two capturing configurations ~40 µs a request against the array store, inside the noise of everything below.
The recorded run
PHP 8.4.23 with OPcache, Laravel 13.21, 11th-gen i7-1165G7 (8 threads), inside the project's
prism-be container while the rest of the stack was running — an ordinary development box under
ordinary load, not a quiet benchmarking rig. 250 requests × 5 rounds, each request running 8
queries, 8 cache read/writes and 1 log line:
| configuration | per request | added | added peak mem | events/req |
|---|---|---|---|---|
| capture off | 1.56 ms | — | — | 0.0 |
| Nightwatch only | 3.59 ms | +2.03 ms | +862 kB | 26.0 |
| Nightwatch + OTel spans | 6.90 ms | +5.34 ms | +2909 kB | 35.0 |
Repeat sweeps on the same box put the two added figures at roughly +2.0–2.4 ms and +4.4–5.6 ms — quote the shape, not the second digit. What does not move is the ordering.
Varying the workload one signal at a time (200 requests × 3 rounds) says where the cost is, and it is not a fixed per-request overhead:
| workload | Nightwatch | Nightwatch + OTel |
|---|---|---|
| nothing (the request alone) | +0.58 ms | +0.81 ms |
| 8 queries | +0.99 ms | +2.60 ms |
| 8 cache read/writes (16 events) | +1.84 ms | +2.41 ms |
Subtracting the empty request, a query costs the capture engine ~51 µs and the span lane a further
~200 µs. The span lane is the bulk of the default install's cost, and what it is buying is visible
in the event counts: a query under the span lane produces two rows — the db bar in the
waterfall and the queries row derived from it — where a flat capture produces one event and no
nesting at all. Nesting is not free.
The per-signal figures are the transferable ones. The headline workload is deliberately signal-dense — 8 queries and 16 cache events inside a request whose own work is 1.5 ms — so it answers "what does one captured operation cost", not "what fraction of a request will this be". Multiply the per-signal numbers by what your own requests actually do. Peak memory moves the same way and for the same reason: the span lane holds a span per operation until the trace ends, which is ~2.9 MB on this workload against ~0.5 MB without it.
Three findings worth keeping:
- Rejecting the superseded records upstream does not recover it. The engine takes its
debug_backtrace(limit: 21)before anyreject*callback runs, soNightwatch::rejectQueries()cannot avoid the expensive part.nightwatch.filtering.ignore_queriescan — it is checked first — and measured at ~90 µs per query in a span-lane install, against a per-request query counter that would then read zero. It is a partial recovery of an inherent cost, not a fix. - The replica-metrics interval throttle costs a cache round trip per request in any process that did not win the interval. It is not capture at all, but it measured 1.19 ms per request against a Redis store — larger than everything above — which is why the benchmark runs its children on the array store and says so.
- Peak memory is bounded by the buffer, not by the request.
PRISM_BATCH_SIZEis what stops a request that issues a hundred queries holding a hundred spans: at capacity the buffer either ships early or drops and counts, and the benchmark reports both numbers.
The lever that is already built for this is PRISM_SAMPLE_REQUESTS. Sampling is per execution:
a request the sampler rejects is discarded whole, spans included, so it never pays the per-signal
costs above. Halving the rate halves the average cost of a request; there is no per-signal rate to
tune and no half-captured trace to reassemble. Turning individual OpenTelemetry instrumentations off
(OTEL_INSTRUMENTATION_QUERY, OTEL_INSTRUMENTATION_HTTP_CLIENT, …) is the other dial, and it costs
that lane its nesting rather than its volume — see
Span lane.
Troubleshooting
Run the built-in diagnostic first — it names the exact problem:
php artisan prism:check
| Symptom | Likely cause |
|---|---|
prism:check reports a missing token or app |
PRISM_TOKEN / PRISM_APP not set, or config cached before they were — run php artisan config:clear. |
A 401 from the endpoint |
The token is wrong, revoked, or not an ingest-scope token. Mint a fresh ingest token. |
A 429 from the endpoint |
The workspace is over its monthly event quota. Connectivity and the token are fine — this is a billing state. Errors are still accepted. |
| Nothing appears in the console | Confirm PRISM_ENABLED is not false, the path/job is not on an ignore list, and — for a worker — that a job has actually run (the batch flushes at the end of each job). |
prism:check reports records are NOT reaching Prism |
The capture engine is running but Prism's ingest was never installed over it — which happens when PRISM_ENABLED is false or PRISM_TOKEN is blank at boot. Fix those and the ingest line reads in-process. |
| You went looking for an OTLP collector to point at | There isn't one. Spans never leave the process as OTLP — all three OpenTelemetry exporters are pinned to null and Prism ships the spans in its own batch. |
You went looking for a nightwatch:agent daemon |
There isn't one. prism:check says agent: not required for exactly this reason — Prism replaces the engine's transport, so nothing is transmitted over a socket and no daemon is installed, started or monitored. |
Nothing appears, and prism:check shows batches "waiting" |
Under spool, batches are landing but nothing drains them: check a worker is consuming the queue named by PRISM_FLUSH_QUEUE (default queue), and that the endpoint is reachable from the worker, which is where the send now happens. |
| Events flow but bodies/args are blank | Expected: sensitive keys are scrubbed at the source, and a body is captured only when request.capture_payload is on and the response was a 500 — both conditions, not either. A queued job's payload is never captured at all. |
| No telemetry after a deploy | Config cache is stale — php artisan config:clear (or re-run config:cache). |
PRISM_ENABLED=false and an over-quota 429 are both success exits for prism:check — a
deliberate opt-out and an operational quota state are not wiring faults.
Tests
The package carries its own Pest suite, run through Testbench with no host application present — which is what proves it depends on nothing outside itself:
cd packages/prism
composer install
./vendor/bin/pest