Picto's core design goal is to reconstruct layout and behavior, not to capture a video or a screenshot. That distinction has real privacy consequences worth being precise about, since "session replay" as a category often implies pixel/video capture — picto deliberately doesn't work that way.
On-screen text#
Text is captured by default: each rendered paragraph is embedded as a readable PNG image with its original font, styling, and layout. Use
CaptureText(enabled: false) to mask a subtree, and ExcludeFromRecording to drop it entirely:
Column(
children: [
Text('Order status'), // captured
CaptureText(enabled: false, child: CustomerDetails()), // line-box placeholders
ExcludeFromRecording(child: PaymentCardForm()), // not recorded at all
],
)
To mask all text app-wide, wrap the root of your app in CaptureText(enabled: false). A nested
CaptureText() cannot re-enable text under a mask, so masking at the root means no readable text is captured anywhere.
These widgets are exported by package:picto/picto.dart. Text remains image-only: Picto does not extract strings, provide search/copy, or run OCR.
CaptureText(enabled: false) masks all paragraphs below it, including single-character inputs and icon-font glyphs. Nested enabled wrappers cannot override a mask.
ExcludeFromRecording takes precedence and removes the subtree's drawings altogether. Both policies reach independently repainting children. Policy changes affect subsequent frames; they do not erase earlier, legitimately captured frames.
Editable fields and passwords are not automatically classified or protected. Because text is captured by default, explicitly mask or exclude every sensitive field, including fields with obscured text.
Text capture has separate limits from image and vector capture: native density, at most 2048 physical pixels on either edge, 8 MiB of held RGBA pixels, 2 MiB of distinct accepted encoded bytes per session, and 4096 raster requests per session. Unchanged paragraphs reuse their raster; distinct paragraph instances can share encoded content after deduplication. A paragraph turned away because too many rasters are encoding at once is captured again on a later frame, once that memory is released. Oversized paragraphs, exhausted session budgets, and capture/encoding failures retain line-box placeholders. The RGBA limit excludes engine overhead and temporary encoder allocations.
Text whose pixels are all one colour — nearly all of it — is stored as that colour plus the paragraph's alpha channel rather than as a full-colour PNG, which measured 68% smaller on a real session. Multi-coloured text, such as a gradient or an emoji, still records as a PNG. This is an encoding detail with no privacy consequence: the same pixels are captured either way, and replay draws them the same.
Each paragraph's painted lines are expanded by overflowPadding for paint that reaches past them. Left unset, it is a quarter of the paragraph's tallest line, which covers italic overhang and stacked diacritics but not a large shadow: set it on a
CaptureText around text whose shadow must be captured. Pixels outside this capture region are not included. Live rendering is unchanged. Text drawn inside a native view, prebuilt picture, or image is outside this paragraph-level control; exclude that whole widget when necessary.
A CaptureVectorGraphics layer containing a text mask or exclusion is omitted as a whole: a layer screenshot cannot redact only its descendant's pixels. New text recordings require the updated replay viewer; old recordings remain readable.
Images and vector graphics#
Same no-content default, different mechanism: images are recorded as a same-sized gray placeholder rect unless they resolve to a known bundled asset (free, no bytes sent — see
Uploading Assets) or the containing widget opts into real capture (CaptureRealImages/CaptureVectorGraphics) — an explicit, size-budgeted choice the host app makes per-subtree, not something picto does automatically.
"Resolves to a known bundled asset" means it goes through an ImageProvider, not merely that the file is declared in your
pubspec.yaml. Picto reads the key off the ImageProvider (AssetImage,
NetworkImage, and anything else keyed by asset path or URL). An image the app decoded itself — from raw bytes, from
dart:ui's decodeImageFromPixels, or through an engine's own asset cache — has no provider and therefore no key, so it falls back to the gray placeholder even though the asset is bundled.
This is the common case in games and custom render loops. Flame, for example, loads sprite sheets through its own
Images cache, so every sprite records as a placeholder by default and the replay shows correctly-positioned gray chips where the art should be.
CaptureRealImages is the opt-in that embeds real pixels, and the bytes are embedded once per distinct image rather than once per draw — a sprite atlas drawn tens of thousands of times costs a single embedded PNG, and needs no separate asset upload to render on replay.
Known limitation —
CaptureRealImagesdoes not currently reach aGameWidget, or any other child that manages its own repainting. Wrapping one has almost no effect: on a real Flame game, 330 of 332 draws still recorded as placeholders.ExcludeFromRecordingandCaptureVectorGraphicsare unaffected and work as documented on the same widgets.
Excluding a subtree entirely#
Wrap anything that should never be captured at all — a payment form, an SSN field, a support-chat transcript — in
ExcludeFromRecording:
ExcludeFromRecording(
child: PaymentCardForm(),
)
This is stronger than the placeholder policies above: nothing about the excluded subtree reaches the recording, not even its position or size. A placeholder box would itself leak "something sensitive is here," which defeats the point for the cases this exists for. Live rendering is completely unaffected — only what gets recorded changes.
What this does not cover#
The policies above are specifically about what a Canvas draws on screen. A few other things you can attach to a session are separate channels — they are not placeholders, and
ExcludeFromRecording has no effect on them, since they're not paint-time draw calls at all:
-
Exception messages and stack traces (
FlutterError.onError/PlatformDispatcher.instance.onError, hooked automatically byPicto.setup()— there is no separate call to make) — if your app embeds real user data into an exception's own message (for example, interpolating an email address into an error string), that text uploads as written. Keep this in mind when writing error messages in your own app. An uncaught error arriving while nothing is recording also starts a session, so a user who never asked for a recording can produce one by hitting a crash — see Errors That Start a Recording. -
Tags (
setTag) and custom events (Picto.recordEvent) — arbitrary key/value strings you choose to attach. See Tags and Custom Events. -
Route names (
Picto.navigatorObserver) — if you add the observer to your app'snavigatorObservers, every route change records the route's ownRouteSettings.name, verbatim. Picto does not read anything off the screen to build it and does not invent one: an unnamed route is recorded as/orunnamed(<RouteType>), never as a guess. But the name you wrote is written as-is, so an interpolated route like/orders/${order.id}puts that id into the recording. Use the route pattern, not the resolved path — see Screen Tracking. -
The end-user identifier (
Picto.identify(userId)) — an explicit, self-attested identifier you provide on purpose. See Identifying Users.
None of these are scanned, redacted, or masked by picto today — that's on the integrating app to manage, the same way you'd manage it for any other logging or analytics SDK.
Network requests are the one exception with its own, narrower policy, not the "arbitrary strings you provide" shape above.
Capturing them at all is opt-in (PictoHttpClient, see Network Requests) — and once opted in, headers and request/response bodies are never recorded, regardless of the on-screen text capture setting.
How long any of it is kept#
An uploaded recording — and everything described above that lives inside its bytes — is deleted 30 days after upload, automatically. The separate channels in the previous section are not on that clock: exception messages and stack traces in particular are stored as their own rows and outlive the recording they came from. Recording Retention traces exactly which of these survives the sweep and which does not.
Where rendering happens#
Picto's servers store and serve your recording as an opaque blob and never decode it. Every render happens on your own device or in your own browser.