Reference

Capsule format

A .bugcapsule file is gzip-compressed UTF-8 JSON. The spec lives in the Flutter SDK repository as spec/capsule-v1.md. Read this if you write a reader, an integration, or a tool on top of capsules.

{
  "format": "bugcapsule.capsule",
  "specVersion": 1,
  "manifest": { ... },
  "checkpoint": { ... },
  "events": [ ... ]
}

Time

Event t is integer milliseconds since the session anchor, the first rendered frame. Events before it (startup network calls, storage reads) have negative t. manifest.session.wallClockAtAnchorMs maps t to wall time: wallClockAtAnchorMs + t, which is what the replay clock returns.

Manifest

FieldTypeNotes
capsuleIdstring (UUID v4)Generated on device; idempotency key for uploads
createdAtISO-8601When the capsule was written
modedebug | qa | production
app.id, app.version, app.build, app.flavorstringversion and build required
source.gitSha, source.dirtystring?, bool?Missing SHA makes the capsule partial
sdk.name, sdk.versionstringbug_capsule_flutter, bugcapsule-android, bugcapsule-ios, @bugcapsule/web
runtimeobject?{name, version?, language?, frameworks?}; absent = Flutter
capabilitiesmap?supported | partial | unsupported | explicit
stateSchemaVersionintApp-declared version of its serialized state
platform.os, osVersion, localestringos required
platform.screen{width, height, pixelRatio}Logical size; scales pointer replay
session.id, startedAt, wallClockAtAnchorMs, seed, durationMsAll required. seed seeds the SDK random.
failureobject | nullNull for a manual capture with no assertion
eligibility.statuseligible | partial | ineligibleComputed on device
eligibility.missing[{dependency, reason, blocking?}]blocking: true makes it ineligible
integrationsstring[]e.g. ["bloc","dio","go_router","shared_preferences"]
stats{eventCount, droppedEvents, checkpointKind}
labelsmap<string,string>Free-form tags; scrubbed like free text
redaction{extraKeys, extraHeaders}?Rules used on top of the defaults; replay applies them so keys match

Failure and signature

{
  "kind": "exception | assertion | manual",
  "signature": "exception:StateError:Cart total is negative (#)",
  "type": "StateError",
  "message": "Bad state: Cart total is negative (-3)",
  "stack": "...",
  "assertionId": null,
  "t": 18234
}

signature is the comparison key replay and verify use: <kind>:<type>:<normalized message>. Normalization replaces digit runs with #, hex ids of 8+ chars with #, and cuts to 200 characters. Assertions are assertion:<id>:. Stack traces are informative only.

Checkpoint

{
  "kind": "launch | periodic",
  "t": -412,
  "wallClockMs": 1760870000000,
  "afterSeq": 0,
  "data": {
    "shared_preferences": { "onboarding_done": {"type": "bool", "value": true} },
    "go_router": { "location": "/cart" },
    "bloc": { "CartBloc#0": {"type": "CartBloc", "state": {}, "stateType": "CartLoaded"} }
  }
}

The capsule's events are exactly those with seq > afterSeq. Each data key belongs to one integration; only it reads it. Each platform page lists the keys its integrations write.

Events

{ "seq": 41, "t": 1820, "type": "http", "data": { ... } }

seq is strictly increasing.

TypeDataReplayed?
lifecycle{state}: resumed, inactive, hidden, paused, detachedyes
pointer{phase: down|move|up|cancel, pointer, x, y, kind, buttons} (logical px; web adds target)yes
action{id, payload?} semantic actionyes
text{id, value} tracked textyes
key{phase, keyCode, key} (Android Back)yes (Android)
route{location, source?, action?}no (diagnostic)
state{key, value?}no
bloc.*, riverpod.*, notifier.*, getx.*, mobx.*state-manager activity (Flutter)no
httpsee belowserved as fixture
log{level, message}no
failuresame shape as manifest.failureno (it is the assertion)

http

{
  "id": 7,
  "key": "GET /api/cart",
  "occurrence": 0,
  "method": "GET",
  "url": "https://api.example.com/api/cart?lang=en",
  "requestHeaders": {"accept": "application/json", "authorization": "[REDACTED]"},
  "requestBody": null,
  "status": 200,
  "responseHeaders": {"content-type": "application/json"},
  "responseBody": {"items": []},
  "requestEncoding": "json | text | form | base64 | omitted | truncated",
  "bodyEncoding": "json | text | form | base64 | omitted | truncated",
  "durationMs": 182,
  "error": null,
  "excluded": false,
  "pending": false
}

Network matching

  1. Redact the URL and decode + redact the body (JSON, form fields, text; binary isn't hashed; multipart never).
  2. METHOD host path (host lowercase, no port, empty path /), then ? and the query params minus ignored names, decoded (+ is a space), sorted by name in UTF-16 code-unit order, values in original order, joined name=value with &.
  3. Body (only when complete and ≤ 64 KiB): parse JSON, apply the rule's matchBodyFields then ignoreBodyFields (plain name = any depth, dotted = that path), hash canonical JSON (keys sorted, no whitespace) with two FNV-1a passes, append #<16 hex>.
  4. Keys longer than 8,000 characters keep the first 7,980 plus ~<hash of the whole key>.

occurrence is assigned when the request starts. Replay serves fixtures in occurrence order after the recorded durationMs, so response ordering, and therefore races, is preserved. Beyond the recorded count the last fixture is served again (network.reused).

Eligibility

[{"dependency": "build.gitSha", "reason": "BUGCAPSULE_GIT_SHA was not set at build time"},
 {"dependency": "failure.assertion", "reason": "manual capture without a failure assertion", "blocking": true}]

Other dependencies you'll see: capability.<key> (a capability the capturing SDK lacks), crash.persisted (written by a crash handler; nothing after the crash exists), missing bodies, state managers without serializers.

Validation

A v1 reader (BugCapsule on upload, the SDKs and CLI on decode) accepts a document only if:

Integers are JSON numbers without a fraction (1, not 1.0). Errors name the JSON path of the first problem, e.g. $.events[3].data.key must be a string. Valid and invalid examples live in spec/fixtures/capsule-v1 in the Flutter SDK repository.

Spec v2: interaction dependencies

i

Coming in the next release (Flutter SDK 0.5 and the next Android, iOS and web releases). The spec lives next to v1 as spec/capsule-v2.md. Everything v1 says still holds unless v2 changes it.

v2 adds replay dependencies: WebSocket connections, Flutter platform channel traffic and operations of third-party native SDKs (Firebase, maps, location, payments).

{
  "format": "bugcapsule.capsule",
  "specVersion": 2,
  "manifest": { ..., "dependencies": [ ... ] },
  "checkpoint": { ... },
  "events": [ ... ]
}

When a capsule is v2

Only when it contains at least one interaction dependency, that is, when the app used BugCapsuleWebSocket / BugCapsuleWebSockets / webSocketIntegration, BugCapsulePlatformChannels or a native adapter during the window. Every other capsule is still written as v1, so v1 tools keep working for it.

Compatibility

Dependencies

{
  "id": "ws",
  "kind": "websocket",
  "adapter": "bug_capsule.web_socket_channel",
  "adapterVersion": "0.5.0",
  "schema": 1,
  "required": true,
  "coverage": "partial",
  "operations": ["connect", "send", "close"],
  "tested": [{"package": "web_socket_channel", "version": "3.0.3"}],
  "gaps": [{"code": "payload.binaryMetadata", "count": 2, "detail": "binary frames recorded as metadata"}],
  "stats": {"events": 41, "payloadBytes": 18234, "dropped": 0, "truncated": 0}
}
FieldTypeNotes
idstring (1–100)Unique in the capsule; interaction events name it in data.dep
kindwebsocket | platform.channel | nativeOther values mean a kind this reader doesn't know
adapter, adapterVersionstringThe adapter that recorded it, e.g. bugcapsule-okhttp.websocket, bug_capsule.firebase_auth
schemaint ≥ 1Version of that adapter's event payloads; a replaying adapter supports a range
requiredbooltrue: replay can't be faithful without the adapter. false: diagnostic capture only, never served in replay
coveragecomplete | partial | diagnosticpartial when gaps isn't empty; diagnostic for capture-only adapters
operations, testedstring[]?, [{package, version}]?Informative
gaps[{code, count?, detail?}]?Why coverage is partial (below)
stats{events?, payloadBytes?, dropped?, truncated?}?Counters

At most 100 dependencies. A dependency with a gap also adds a dependency.<id> entry to eligibility.missing.

Payloads

Every value an interaction carries (frames, call arguments, results, error details, stream events) is a payload object:

{"mode": "full", "replayable": true, "value": {"price": 12.5}, "bytes": 15, "kind": "text"}
modeMeans
fullThe value as the app saw it
redactedRedaction changed it. Replayable only when the adapter declares the redacted fields safe to replay
metadataOnly bytes and kind (binary frames without a serializer, payloads past the per-capsule budget, metadata-only sockets)
omittedNothing kept: an app rule excluded it, or body capture is off
truncatedLarger than the payload limit; at most a prefix is kept as a preview

Only full and redacted payloads can be replayable. Replay that needs a non-replayable payload reports <family>.noPayload, which is material. Values are typed so they survive the round trip: plain JSON, plus objects tagged with $t for 64-bit integers, whole-number and non-finite doubles, byte arrays, typed number lists, and maps with non-string keys. A value the encoder can't represent becomes {"$t": "unsupported", …} and makes the payload non-replayable. Match keys are computed from redacted values only.

Interaction events

Interaction events keep the v1 envelope (seq, t, type, data) and carry data.dep. Outbound events (the app asks the dependency) are matched during replay; inbound events (the dependency tells the app) are delivered as recorded.

FamilyOutboundInbound
WebSocket (websocket)ws.connect, ws.send, ws.close from the clientws.open, ws.message, ws.error, ws.close from the server
Platform channels (platform.channel)platform.call from Dart, platform.listen, platform.cancelplatform.result, platform.error, platform.event, platform.done, platform.call from the platform
Native adapters (native)native.call, native.subscribe, native.cancelnative.result, native.error, native.event, native.done

Preflight and checkpoints

Before the app under replay starts, every dependency with required: true needs an installed adapter with the same adapter id that supports its schema. Otherwise the attempt ends incompatible with dependency.unsupported (or dependency.schema). Dependencies with required: false never block. A periodic checkpoint lists connections and subscriptions that were open in checkpoint.data.interactions.open:

{"open": [{"dep": "ws", "type": "ws", "id": "ws3", "restorable": false}]}

Native handles are never serialized. An entry that isn't restorable adds a blocking interactions.restore eligibility gap, and replay ends incompatible with dependency.unrestorable. Launch checkpoints replay connections from the start of the session and aren't affected.

Bounds and gaps

Gap codes: payload.truncated, payload.dropped, payload.binaryMetadata, payload.redacted, connections.limit, codec.unsupported, channel.late, adapter.captureFailed, operation.unsupported, events.dropped. SDKs may apply lower limits, and your project's capture settings can lower the payload limit further or turn off one adapter. On a capture failure the app still gets the real result and the adapter records adapter.captureFailed.

v2 validation

On top of the v1 checks, a v2 reader requires:

Interaction events in a v1 document are not validated beyond the v1 rules. Shared examples live in spec/fixtures/capsule-v2.