Skip to content

Cookbook: NativePHP (mobile & desktop)

Cookbook: NativePHP

Verified against nativephp/mobile v4 (the SuperNative line, PHP ^8.4) and nativephp/desktop v2.

Both targets break the same two assumptions the rest of this package is built on: there is no Redis and no APCu, and nothing can scrape a Prometheus endpoint on a device you don't control. Desktop breaks nothing else. Mobile breaks one more, and it is the interesting one — see Mobile below.

What you get

The payoff is the trace that crosses the device boundary. Outbound HTTP is already instrumented and already propagates W3C traceparent, so a tap on a phone and the SQL query it caused on your server land in one waterfall. Nothing extra is needed for that — it falls out of turning telemetry on.

Desktop (v2)

Desktop runs an embedded PHP server behind Electron, so there is a real request cycle: TraceRequest, queries, cache, queue and command instrumentation all work unchanged. Only storage and export need answering.

TELEMETRY_STORE=sqlite
TELEMETRY_PROMETHEUS_ENABLED=false
TELEMETRY_EXPORTERS=otlp
TELEMETRY_OTLP_SPOOL=true
TELEMETRY_OTLP_SPOOL_DRIVER=sqlite

SQLite rather than array is not a preference here — a desktop app typically runs its queue worker and scheduler as separate PHP processes, and they write the same series as the server. See decision 0001.

The spool matters because a laptop closes mid-request. Entries survive in storage/framework/telemetry-spool.sqlite and ship on the next run of telemetry:flush.

Mobile (v4)

Same config as desktop. The difference is what a "request" means.

NativeRouter::start() enters a loop that calls $component->runLoop(), and that loop holds one request open for as long as the user stays on the screen, blocking between interactions. So Kernel::terminate() never fires while someone is using your app, and the per-request flush the rest of this package relies on never happens. Left alone, a native screen records telemetry that is never shipped.

Screen instrumentation comes in two halves, and only one of them is automatic.

Screen views — automatic

On nativephp/mobile ^4.1, which dispatches the screen lifecycle events (Native\Mobile\Events\Screen\*, added in mobile-air#248), the package listens for them behind a class_exists guard, exactly like the Horizon and Pennant integrations. Nothing to wire:

Signal What it tells you
screen.view event Navigation — carries screen.name, screen.uri, and whether it was a fresh push or a resume
screen.views counter Views per screen
screen.view.duration Time spent per screen, measured mount → unmount

On 4.0.x the guard simply never arms and none of the above appears — nothing breaks, and the opt-in half below still works. 4.1.0 is the first release carrying the events.

Interactions — still yours to wire

Those events cover mount() / onResume() / unmount() only. A tap goes through NativeComponent::dispatch(), which upstream does not announce — and that is exactly where the flush has to happen, because the runloop holds its request open across every interaction on the screen. So interaction spans need two forwards from one shared base class:

use Cbox\Telemetry\Instrumentation\NativeScreenInstrumentation;
use Native\Mobile\Edge\NativeComponent;

abstract class Screen extends NativeComponent
{
    protected function dispatch(array $event): void
    {
        $this->telemetry()->aroundInteraction(
            static::class, 'interaction', $event, fn () => parent::dispatch($event),
        );
    }

    protected function dispatchNativeEvent(array $event): void
    {
        $this->telemetry()->aroundInteraction(
            static::class, 'native_event', $event, fn () => parent::dispatchNativeEvent($event),
        );
    }

    private function telemetry(): NativeScreenInstrumentation
    {
        return app(NativeScreenInstrumentation::class);
    }
}

Point your screens at Screen instead of NativeComponent and you get:

Signal What it tells you
screen.interaction span How long the user waited after a tap, with the queries and HTTP calls it caused nested inside
screen.native_event span The same for bridge callbacks — a photo returning, a scan resolving
screen.interaction.duration Interaction latency by screen
screen.interactions.failed Interactions that threw

Spans carry screen.name and screen.event.type; the duration histograms are labelled {screen} and {screen,type}. The whole integration sits behind instrument.native_screens — turn it off and those forwards pass straight through, which is how a consent prompt answered mid-session takes effect without a restart.

Each interaction is its own trace root, flushed on the spot. It is deliberately not a child of a screen-wide span: that span would stay open for minutes and never reach an exporter.

What is deliberately not instrumented

Do not forward mount(), onResume() or unmount() from your base class, even though it would work. A screen that defines its own mount() silently replaces the base class's, and instrumentation that quietly stops working on exactly the screens with the most logic in them is worse than instrumentation that was never there. That is the gap the upstream events close, and why the automatic half above exists.

If you already forward runLoop() from an older setup, leave it — once the events are available aroundScreen() steps aside on its own rather than counting every view twice.

The render frame stays out of reach: NativeComponent::renderToElement() is private, so there is nothing to wrap.

The persistent runtime

NativePHP boots the app once and dispatches through the same container, which is Octane's problem in a smaller box. The package registers a reset on Native\Mobile\Runtime::onReset() automatically when NativePHP is installed — no configuration. Note it only covers the web/Livewire path; SuperNative screens never reach Runtime::dispatch(), which is why NativeScreenInstrumentation resets context per interaction itself.

Before you ship this

The app is a public client. An OTLP token compiled into a bundle can be extracted from it — treat anything you put in TELEMETRY_OTLP_HEADERS as published. Send device telemetry through an ingest gateway that issues per-install tokens and can revoke them, not straight to your collector.

This is end-user data. Device telemetry is personal data in a way server telemetry usually isn't. Gate it behind consent — telemetry.enabled is a runtime config value, and disabled means no listeners, no-op instruments and no providers booted, so an opted-out user costs nothing.