Logopicto

Starting & Stopping a Recording

The recording lifecycle — Picto, PictoService, CanvasRecorder, and the built-in duration cap.

Most apps only ever need Picto — see Quick Start. This page also covers PictoService, the lower-level primitive Picto itself is built on, for apps that want more direct control (e.g. the reference app's standalone mode, which runs with no backend configured at all — see apps/mobile/lib/main.dart).

The usual way: Picto#

Picto.startRecording();

A no-op if already recording. This is the only way a session begins deliberately, and Picto.setup does not start one — see Quick Start for why.

It is not the only way recording ever starts, though. Foregrounding the app starts it again after a backgrounding ended it, continuing the same session — for an app that asked to be recorded (Sessions & Segments); an uncaught error starts a session of its own (Errors That Start a Recording); and an error capture window closing starts a new session in place of the one it just ended. None of the three is a session you asked for, and all three exist so that "the user left for a moment", "the app broke" and "the app broke twice" do not silently mean "captured nothing".

"A no-op if already recording" is the whole of it#

That sentence is easy to read as "and the next call starts a second recording". It does not. A startRecording() made while a session is live changes nothing at all: the session already running keeps its own session_id, its own clock and everything it has captured so far, and the call returns having done nothing. It is not an error and nothing is lost — but if you were expecting a fresh recording from that point, you did not get one.

A session has to have ended — and stayed ended — before startRecording() can begin a new one, and "stayed ended" is the hard part: neither of the two things that end one on your behalf leaves nothing running. A backgrounding is a cut, because foregrounding starts recording again on the same session (Sessions & Segments); an error capture window closing is a cut too, because it starts a new session straight away. So calling this twice at two points in a long session may produce two recordings and may equally produce one, depending on what happened in between — which is not something your code can see.

When you want a cut at a specific moment, say so:

final outcome = await Picto.restartRecording();

See restartRecording below.

The one thing that begins a session without being asked is an uncaught error: if one arrives while nothing is recording, picto starts a session for it so the error is not lost just because the app happened not to be under observation. See Errors That Start a Recording.

It is also a no-op on a run that sampling declined — by default every run is recorded, but a build made with a lower percentage honours this call on only that share of its runs.

You can end a session yourself at any time:

final ok = await Picto.endSessionAndUpload();

Other things end one on your behalf too, so don't assume a session runs until you say otherwise. The app being backgrounded or terminated ends the recording and uploads it — though for an app that called startRecording() that one is a cut rather than a stop, since foregrounding continues the same session as its next segment (Sessions & Segments). For a session an uncaught error started on an app that never called it, the backgrounding is a full stop. So does an uncaught error: the first one of a session schedules the session's end, which is also why a session that errored may upload only part of itself — see The Error Capture Window.

false does not mean the upload failed#

Because those other things end sessions on your behalf, a manual call frequently arrives to find no session left to end — and endSessionAndUpload reports that as false, the same value it uses for a genuine upload failure. Logging false as "upload failed" is therefore wrong about as often as it is right.

When you want to act on the result, ask for the outcome instead:

final outcome = await Picto.endSessionAndUploadOutcome();

It returns a PictoSessionEndOutcome — uploaded, uploadFailed, nothingRecording, or recordingPipelineAbsent — so "nothing to do" and "the backend refused you" stop looking identical. The four values are described in the configuration reference. endSessionAndUpload is unchanged and still returns true for exactly one thing: the recording reached the backend.

An uploadFailed is not a loss either — the recording is queued on disk and the SDK drains the queue itself at launch and on resume. The console says so too, naming the transport error it gave up on. See Uploading a Session for what ending a session actually sends, and for what happens to one that cannot be sent.

(Separately, the built-in duration cap does not end a session — it stops the recorder accepting new events while the session stays open.)

Restarting: ending one session and starting the next#

final outcome = await Picto.restartRecording();

Ends the current recording, uploads it, and starts a genuinely new session — its own session_id, its own clock, its own embedded content, its own hadError. Use it at a boundary that matters to you rather than to the app lifecycle: a user logging out, a support session beginning, the start of a checkout flow you want to be able to find on its own.

It is the sequence you would otherwise write by hand, in the right order:

// What restartRecording() does for you.
await Picto.endSessionAndUpload();
Picto.startRecording();

The order is the part worth having done for you. startRecording() before the upload has finished swaps the recorder out from under the flush that is still writing the previous session's file.

It returns the outcome of the session that ended. That is the same PictoSessionEndOutcome endSessionAndUploadOutcome returns — there is no separate vocabulary for the restart, because the new session either started or the call returned null.

Return valueWhat happened
uploaded The old recording reached the backend, and a new session is running.
uploadFailed The old recording is queued on disk for a later retry, and a new session is running.
nothingRecording Nothing was recording, so nothing was uploaded — and a new session is running.
recordingPipelineAbsent No recording pipeline, so the old session was empty by construction. Expected under test.
null This run was not sampled . Nothing happened at all — no session ended, and none started.

nothingRecording is the normal case, not a mistake: something has usually already ended the session for you. You do not need to check Picto.isRecording first — a restart with nothing recording simply starts one, because "a fresh session from here" is what you asked for either way.

null is the one value that means nothing happened. On a run sampling declined, startRecording() is already a no-op, and restartRecording() matches it — including leaving alone the one kind of session that can be live on such a run, the one an uncaught error started. Ending that session early to replace it with nothing would lose the capture window that is the whole point of it.

Metadata passes through to the ending session's upload, exactly as with endSessionAndUpload:

await Picto.restartRecording(metadata: {'reason': 'user logged out'});
Tags do not carry across the cut. `Picto.setTag` is session-level by design, so a tag you want on the new recording has to be set again after the restart. The user named by `Picto.identify` **does** carry — a user does not stop being that user because a new recording started.

The lower-level primitive: PictoService#

PictoService wraps a CanvasRecorder and exposes the same start/stop/tag primitives Picto itself calls internally — construct it wherever you need it (it's a cheap, stateless wrapper — the actual recorder state lives on the CanvasRecorder you pass in, not the service object itself):

final service = PictoService(canvasRecorder);

Checking whether recording is active#

if (service.isRecording) { ... }

Starting#

service.startRecording();

A no-op if already recording. If the recorder was constructed with startActive: false, this is what actually turns it on.

Ending a session#

final segment = await service.endSession();

This stops the recorder (no further activity is captured) and flushes everything recorded so far to an automatically-chosen file under Directory.systemTemp — you never need to come up with a path yourself. The returned RecordingSegment has two fields:

FieldMeaning
path The file endSession just wrote — hand this to RecordingUploader.upload
sessionIdA random ID identifying this recording session

A user can start a fresh recording at any point during their time in the app, and each one is a complete recording uploaded on its own — nothing is concatenated on the device, and no upload waits for a later one.

Uploaded on its own is not the same as unrelated, though. PictoService is the lower-level path, so it does exactly what you tell it and never continues a session by itself; but the Picto facade above it does, on a foregrounding, by handing the next recorder the previous sessionId and the next sequence. Several complete uploads sharing one session_id is how a session that spanned a backgrounding is represented — see Sessions & Segments.

The built-in duration cap#

A CanvasRecorder stops accepting new events automatically once it has been recording for 10 minutes — a recording is uploaded in one request when it ends, not streamed as it runs, so this cap is what stops a runaway recording from growing forever before it's ever flushed. flush/endSession still work normally past the cap; they just return whatever was captured up to that point, so a recording that hits the ceiling still uploads — it's the tail that's missing, not the recording.

The cap is per recorder, and therefore per segment. A session resumed after a backgrounding gets a new recorder with the full ceiling again (Sessions & Segments), so ten minutes bounds how much sits in memory at once — it is not a ten-minute limit on how long a session_id may live.

Changing it#

The ceiling is configurable, per recorder. Through the facade, set it once at setup and it governs every session of the app run — the first one, every later one, and the session an uncaught error starts on its own:

Picto.setup(
  apiKey: '...',
  appVersion: '1.0.0',
  maxRecordingDuration: const Duration(minutes: 30),
);

On the lower-level path above, pass it to the recorder you build yourself:

final recorder = CanvasRecorder(maxDuration: const Duration(minutes: 30));
final service = PictoService(recorder);

Omit either one and you get the 10-minute default. A recorder's own maxDuration wins over that default; Duration.zero is accepted and caps the recording immediately.

Raising the cap raises the worst case, not the typical one. A recording that actually runs to a 30-minute ceiling holds three times as much recorded data in memory before it is flushed, and uploads a file to match — on one request, since a recording is never split across requests. Raise it because your sessions genuinely run that long in the foreground without a break, not as insurance.