Logopicto

Uploading a Session

When a finished recording leaves the device, what one upload actually contains, and what happens when it fails.

A recording is not uploaded while it runs. The SDK captures into memory, and a recording becomes an upload only when it ends — at which point the whole thing goes up in one request, with the exceptions, frozen frames and rage-tap clusters captured during it following immediately behind it.

One session can produce more than one such upload. A backgrounding ends and uploads what has been captured so far, and coming back continues the same session as its next segment — see Sessions & Segments. This page is about what one of those uploads is; that page is about how several of them relate.

Nothing on this page needs wiring. Once a session is running, everything below happens whether or not your app calls anything else.

What triggers an upload#

Three things end a recording and upload it. Your app controls exactly one of them.

The app being backgrounded or terminated#

This is the usual one. Picto.setup installs an AppLifecycleListener, and on AppLifecycleState.paused or AppLifecycleState.detached it ends the live recording and uploads it — no call from you.

It is the trigger that matters most, because it is the one that fires when a user simply leaves. A recording that ran for eleven minutes and was never explicitly ended still arrives, in full, the moment the user swipes to another app.

It is not the end of the road, though. Foregrounding the app starts recording again, on the same session, as its next segment — so a backgrounding is a cut rather than a stop. Sessions & Segments covers what that means for your row count and your bill.

Ending the session yourself#

final ok = await Picto.endSessionAndUpload();

The manual override — a "Report a bug" button, a sign-out, the end of a support flow. It does the same thing the lifecycle listener does, at a moment you choose.

false here is not "the upload failed": it is also what you get when something already ended the session for you, which is common. endSessionAndUploadOutcome() tells the cases apart.

Picto.restartRecording() is this trigger followed by a fresh session, in the right order.

The error capture window closing#

The first uncaught error of a session arms a timer, and when it expires the session ends and uploads. The full rule is min(next backgrounding, errorWindowAfter) — the timer is a ceiling, and being backgrounded usually gets there first. errorWindowAfter defaults to 30 seconds.

That is deliberately two mechanisms rather than one: a process that dies never fires a timer, and a session that errors and then keeps running in the foreground for an hour would never see a backgrounding. See The Error Capture Window for what such an upload contains, which is not the whole session.

When the timer is what closes it, recording starts again immediately under a new session_id — so this trigger is a cut rather than a stop, and a second error later in the run produces an upload of its own.

What one upload contains#

A single POST carries one recording as one complete object. There is no chunking: a recording is never split across requests, and it is uploaded whole, exactly once.

What a single upload is not is necessarily the whole session. A session that spanned a backgrounding arrives as several of these requests, each one complete in itself, tied together by session_id and ordered by sequence — see Sessions & Segments.

Alongside the recorded bytes it carries:

FieldWhere it comes from
platformdefaultTargetPlatform.name
app_version The appVersion you passed to Picto.setup
format_version The recording format the SDK wrote — never yours to pick
duration_ms See below — not always the wall clock
session_id The session this recording belongs to — shared with every other segment of it
sequence Which segment of that session this is, counting from 0. A session that only ever uploaded once sends 0
end_user_id Whoever Picto.identify named, omitted entirely when nobody did
metadata hadError , your tags if any, a frame-jank summary, plus anything you passed to endSessionAndUpload(metadata: …)

A metadata key of your own with the same name as one of the automatic entries wins over it.

The signals go up behind the recording, never ahead of it#

Exceptions, frame-jank events and rage-tap clusters — the three signals webhook alerts fire on — are handed to the uploader as part of the same call that sends the recording, and they are posted only once the recording's own row has been accepted. If the recording is queued instead of accepted, they are queued with it — written into the same sidecar and sent alongside it on the next drain.

They are separate requests, but they are strictly ordered behind the recording, and that ordering is the whole point: a signal can never outlive the session it points at.

This is not a design detail; it is a fix for a real, shipped bug. Signals used to be posted independently of the recording, and signal rows carry no billing gate server-side while a recording does. So a dashboard listed frozen frames whose "open at this moment in the replay" link was a permanent dead end — the recording POST had been refused with a 403 for having no active subscription while every signal POST sailed through.

Once the recording is stored, a signal that still cannot be delivered is retried and then queued on its own, rather than dropped. It cannot recreate the bug above by outliving anything: the recording it points at is already on the server by then.

duration_ms is not always the wall clock#

The length a recording is uploaded with is min(elapsedMs, contentEndMs) — the session's wall clock, cut back to where its content actually ends.

Both halves of that rule are load-bearing:

  • A trailing stretch with nothing on the wire is not video. No frames, no input — declaring it would put 30 seconds of nothing on the Sessions list, on the replay transport, and in the usage rollup. That stretch is exactly the after-window of an uncaught error in every session where the app does nothing more after the error.
  • Content past the wall clock is never a reason to declare more. Real flushes do run a few frames past the clock read, and the session's length is still the session's length.

A screen that stops changing while input keeps arriving is not trimmed. Input is on the wire, so the content end keeps moving — this trims only what the recorder can prove was never there.

Said out loud, because it does not follow from the name: the server sums duration_ms into your subscription's usage for the period. So trimming an empty tail lowers what you are billed for that recording. That is intended. Billing for seconds that hold no video is over-billing.

The sum is over every recording in the period, with no grouping by session, and each segment's duration_ms is that segment's own length rather than a running total. So a session that arrives as five segments contributes all five of their lengths — which is correct, because all five hold video, but it is a different number from what the same app produced before backgrounding stopped being terminal. Sessions & Segments says what that shift looks like.

What is never uploaded#

A session no binding captured. If picto is not the live WidgetsBinding, the recording is empty by construction, so the session is ended and discarded with no request at all — not queued, not written to disk. endSessionAndUploadOutcome() reports this as recordingPipelineAbsent, distinct from a failure.

That is every widget and integration_test run in your own app, by design: your CI cannot put rows in your production dashboard, no matter which code paths your tests exercise. See Testing for how every upload path stands down under a test binding — including the SDK's own automatic queue drains, which leave a real queue from a device run completely untouched.

A session that sampling declined never started. On a run sampling did not select, Picto.startRecording() is a no-op, so there is no session to upload and no request. Picto.restartRecording() returns null on such a run and does nothing at all — it does not end the live session and does not start a new one.

But sampling does not stop error sessions from uploading. An uncaught error starts a session even on a run sampling declined — the error path bypasses the sampling gate structurally, because "the app was not under observation" is exactly the case that feature exists for. That session then ends and uploads like any other. If you are sizing expectations against a low sampling percentage, this is the line to read twice: sampled out means no deliberate recordings, not no uploads.

A failed upload is queued, not lost#

An upload retries transient failures — a dead socket, a timeout, a 503 — on a backoff ladder. When the ladder runs out, the recording is not deleted. It is moved into a queue on disk and re-sent later.

Where the queue lives#

picto_pending_uploads/ under the system temp directory, unless you passed pendingUploadsDir to Picto.setup. Each queued recording sits there as its own file with a small sidecar next to it, holding the request body it failed to send: platform, app_version, your metadata, and any signals that were meant to travel with it.

The sidecar is what makes the queue self-describing. The raw recorded bytes carry none of that, so without it a retry would have to ask your app to reconstruct metadata for a file it had never seen. Because the sidecar exists, retryPendingUploads() takes no arguments and you have nothing to remember.

The one field the sidecar records but never gets to decide is the API key. A queued recording is always re-sent under the key the app is running with now, overriding whatever was written at the time, so an app that shipped with a rotated or corrected key does not keep replaying the old one.

The SDK drains the queue itself#

You do not have to call anything. The SDK drains the queue on its own initiative, from two triggers:

  • At Picto.setup, once per launch. This is what covers an app that never wired draining up at all.
  • On AppLifecycleState.resumed, every time the app is foregrounded. This is the far more common shape: the app is already running when connectivity comes back, or when the rate-limit window that rejected the last attempt finally rolls over. Launch alone would make a queued recording wait for the next cold start, which on a long-lived app can be days.

Both are throttled to at most one drain every five minutes, because resumed fires far more often than "the user opened the app" — every notification shade, every app switcher, every permission dialog.

A drain never runs concurrently with itself, whoever asked. It is also ordered strictly after any in-flight session upload rather than interleaved with it, which matters more than it sounds: a failing upload writes its bytes into the queue and then writes the sidecar, and a drain that listed the directory in the window between those two writes would find a recording it could never reconstruct and delete it. Backgrounding an app and immediately foregrounding it is exactly that window.

Earlier versions of these docs told you to call Picto.retryPendingUploads() from your own main(). That line is now redundant and can be deleted. The call stays public and supported for draining at a moment you know is better than either automatic trigger — just after your own connectivity monitor reports the device is back online, say. See retryPendingUploads for the details.

Note that the queue delay is the one case where the retention clock visibly diverges from when a session was recorded: the 30 days run from when the upload lands, so a session captured offline on the 1st and drained on the 10th is deleted around the 10th of the following month.

Rate limits, and what a burst of sessions feels like#

Uploads are rate limited per IP address, and the recording endpoint has the tighter budget of the two:

WhatBudget
Recording creates20 per 5 minutes
Exception rows60 per 5 minutes
Rage-tap cluster rows60 per 5 minutes
Frame-jank rows120 per 5 minutes

The signal tables get more headroom because there is one row per signal rather than one per session: a single crash-heavy session can legitimately throw more than once, and a scroll-jank storm can produce more frozen frames than any session would ever throw exceptions. The recording row is the one with the tight budget, and it is the one that matters — a refused signal is retried and queued behind a recording that is already stored.

The limiter uses a fixed window, not a sliding one. It stamps the start of a window on the first request in it and resets only once the full five minutes have passed; a refused request neither extends the window nor brings the reset closer. So a 429 clears in whatever is left of five minutes, which is minutes — not seconds.

That is why there is no in-session retry ladder for a rate-limited recording. Three more attempts twenty seconds apart cannot outlast a window with minutes to run, and would only spend requests at an already-exhausted limiter — from every device sharing that IP. The recording is queued instead, and the next drain sends it.

What this means for you: if your app starts many short sessions in a burst — a test device looping a flow, a fleet of devices behind one office NAT — expect some of them to queue rather than upload immediately. Nothing is lost, and nothing needs handling in your code; the sessions simply arrive a few minutes later than they were recorded.