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": [ ... ]
}
- Readers MUST reject a
formatother thanbugcapsule.capsuleand aspecVersiongreater than they implement. - Readers MUST ignore fields and event types they don't know (forward compatibility). Writers MUST NOT change the meaning of existing fields within v1.
- A change that can't be expressed by adding optional fields needs a new
specVersion. Spec v2 (next release) adds interaction dependencies.
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
| Field | Type | Notes |
|---|---|---|
capsuleId | string (UUID v4) | Generated on device; idempotency key for uploads |
createdAt | ISO-8601 | When the capsule was written |
mode | debug | qa | production | |
app.id, app.version, app.build, app.flavor | string | version and build required |
source.gitSha, source.dirty | string?, bool? | Missing SHA makes the capsule partial |
sdk.name, sdk.version | string | bug_capsule_flutter, bugcapsule-android, bugcapsule-ios, @bugcapsule/web |
runtime | object? | {name, version?, language?, frameworks?}; absent = Flutter |
capabilities | map? | supported | partial | unsupported | explicit |
stateSchemaVersion | int | App-declared version of its serialized state |
platform.os, osVersion, locale | string | os required |
platform.screen | {width, height, pixelRatio} | Logical size; scales pointer replay |
session.id, startedAt, wallClockAtAnchorMs, seed, durationMs | All required. seed seeds the SDK random. | |
failure | object | null | Null for a manual capture with no assertion |
eligibility.status | eligible | partial | ineligible | Computed on device |
eligibility.missing | [{dependency, reason, blocking?}] | blocking: true makes it ineligible |
integrations | string[] | e.g. ["bloc","dio","go_router","shared_preferences"] |
stats | {eventCount, droppedEvents, checkpointKind} | |
labels | map<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.
| Type | Data | Replayed? |
|---|---|---|
lifecycle | {state}: resumed, inactive, hidden, paused, detached | yes |
pointer | {phase: down|move|up|cancel, pointer, x, y, kind, buttons} (logical px; web adds target) | yes |
action | {id, payload?} semantic action | yes |
text | {id, value} tracked text | yes |
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 |
http | see below | served as fixture |
log | {level, message} | no |
failure | same shape as manifest.failure | no (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
}
- Recorded when the exchange completes:
tis when the response (or error) arrived; the request was sent att - durationMs. - Bodies are never buffered whole: a bounded prefix is kept while bytes stream to the app.
omitted= body capture off or the request excluded (excluded: true, no headers either);truncated= over the limit, still streaming after the wait limit, or failed mid-stream. - A fixture without a body makes the capsule
partial; replay reportsnetwork.noBodywhen it serves one. error.type:connectionError,connectionTimeout,receiveTimeout,cancelin every runtime.pending: true(Android, iOS) = still in flight when the capsule was written.
Network matching
- Redact the URL and decode + redact the body (JSON, form fields, text; binary isn't hashed; multipart never).
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, joinedname=valuewith&.- Body (only when complete and ≤ 64 KiB): parse JSON, apply the rule's
matchBodyFieldsthenignoreBodyFields(plain name = any depth, dotted = that path), hash canonical JSON (keys sorted, no whitespace) with two FNV-1a passes, append#<16 hex>. - 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:
format,specVersion,manifest,checkpoint,eventsare present and typed.- manifest:
capsuleId(1–100 chars),createdAt(ISO-8601 with zone),mode,app.version,app.build,platform.os,sessionfields andeligibility.status; optional fields have their types;failure.kindandsignaturerequired inside a failure;runtimeandcapabilitiestyped when present. - checkpoint:
kind,t,wallClockMs;afterSeq ≥ 0;dataan object. - events: integer
seq(strictly increasing), integert,type(1–100), objectdata; known types carry the fields replay reads. - Bounds: ≤ 200,000 events, depth ≤ 100, ≤ 100 labels, ≤ 100 integrations, ≤ 500 eligibility entries, ≤ 4,000,000 JSON values (counted before parsing), decompressed ≤ min(10 ×
maxCapsuleBytes, 64 MB) on upload.
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
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
- A v2 reader reads v1 and v2 and rejects a
specVersiongreater than 2. - Older CLIs and SDKs are v1 readers: they reject a v2 capsule through the
specVersioncheck instead of replaying it without its dependencies. Update the CLI everywhere it runs, CI included, before you enable these integrations. - Tools never turn a v2 capsule into v1 by dropping events.
- A v2 document without
manifest.dependenciesis invalid.
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}
}
| Field | Type | Notes |
|---|---|---|
id | string (1–100) | Unique in the capsule; interaction events name it in data.dep |
kind | websocket | platform.channel | native | Other values mean a kind this reader doesn't know |
adapter, adapterVersion | string | The adapter that recorded it, e.g. bugcapsule-okhttp.websocket, bug_capsule.firebase_auth |
schema | int ≥ 1 | Version of that adapter's event payloads; a replaying adapter supports a range |
required | bool | true: replay can't be faithful without the adapter. false: diagnostic capture only, never served in replay |
coverage | complete | partial | diagnostic | partial when gaps isn't empty; diagnostic for capture-only adapters |
operations, tested | string[]?, [{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"}
mode | Means |
|---|---|
full | The value as the app saw it |
redacted | Redaction changed it. Replayable only when the adapter declares the redacted fields safe to replay |
metadata | Only bytes and kind (binary frames without a serializer, payloads past the per-capsule budget, metadata-only sockets) |
omitted | Nothing kept: an app rule excluded it, or body capture is off |
truncated | Larger 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.
| Family | Outbound | Inbound |
|---|---|---|
WebSocket (websocket) | ws.connect, ws.send, ws.close from the client | ws.open, ws.message, ws.error, ws.close from the server |
Platform channels (platform.channel) | platform.call from Dart, platform.listen, platform.cancel | platform.result, platform.error, platform.event, platform.done, platform.call from the platform |
Native adapters (native) | native.call, native.subscribe, native.cancel | native.result, native.error, native.event, native.done |
- Connects, calls, listens and subscriptions carry a
key(scope, method and a hash of the redacted, normalized arguments, using the request-key hash) and anoccurrence, so identical concurrent calls are matched by invocation order. Sends and client closes are matched by order within their connection, and their payload is compared. - An inbound event is delivered its recorded delay after the outbound event that preceded it in the same scope was matched, never before an earlier inbound event of that scope. Idle compression never skips past a due delivery.
- A null result, a missing handler, a thrown error, stream completion and cancellation are distinct events. When an adapter uses a captured channel or socket internally, the lower-level events are suppressed or marked
"diagnostic": trueand never served, so no callback is delivered twice. - Native-adapter events reproduce what the app saw (a Firestore snapshot, a location fix, a payment-sheet outcome), never native rendering or the provider's behaviour.
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
- 64 KiB per payload (encoded JSON); larger ones are
truncated. - 1 MiB of interaction payloads per capsule; later ones are
metadata, counted instats.dropped. - 32 captured connections and subscriptions at a time; more are not captured (
connections.limitgap). - Binary frames stay
metadatauntil the app registers a serializer (payload.binaryMetadata).
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:
specVersion1 or 2; for 2,manifest.dependenciesis an array of at most 100 objects withid(unique),kind,adapter,adapterVersion, integerschema≥ 1, booleanrequiredand a knowncoverage; the optional fields have the types above.- Every
ws.*,platform.*andnative.*event has a stringdata.depnaming a declared dependency, plus the ids, keys, occurrences and indexes replay reads. - Every payload field present (
payload,args,value,details) is an object with a knownmodeand a booleanreplayable;bytes, when present, is an integer ≥ 0. checkpoint.data.interactions.open, when present, is an array of objects with stringdep,typeandid.
Interaction events in a v1 document are not validated beyond the v1 rules. Shared examples live in spec/fixtures/capsule-v2.