Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,38 @@
compare by name, a custom detector can name its own, and a reader keeps
a unit it does not know by name instead of dropping the measurement.
- `SimpleStructuralDetector.report` takes an optional `measurement`.
- Snapshots and `ext.sleuth.issues` carry `notUsefulMarks`, the issues
marked Not useful in the overlay this session (oldest first, at most
200), each with the issue's key, title, category, severity, confidence,
measurement, route, `markedAt` and whether it is still reported
(`live`). Like `suppressedCount` the key is metadata: written only when
there is a mark, whatever the projection, and never a `SnapshotSection`.
`SessionSnapshot.notUsefulMarks` and `NotUsefulMark` read it back.
`doc/mcp_schema.{json,md}` document it and the issue `measurement`
shape, and the sidecar lists it in `snapshotMetadataKeys`.
- `WidgetHighlight.occurrenceId`: the identity of the element a highlight
was measured from, equal to the `occurrenceId` of its issue.
- `DebugSnapshot.forcedRebuildsOf` (debug callbacks): for each widget type
a parent's build rebuilt, the types whose builds forced it, framework
widgets included, and how often. `forcedTypesCapped` reports its caps.

### Changed

- `FrameVerdict.relatedIssues` lists only the issues that can explain the
verdict's phase: the issues of the phase's category, `event_loop_stall`
for a start delay, and none for a stall or an unknown phase. Jank
symptoms are never listed, so exports carry fewer issues, and the
reason's `Related:` line names an issue of the phase. The overlay's JANK
tag shows only while `jank_detected` or `sustained_jank` is reported.
- Highlighting an issue outlines only its own widget: the element it was
reported for, else the first widget with its name. Another widget of
the same category is no longer outlined in its place. When the widget
is not on screen the card unticks, and no outline appears later.
- Copying the snapshot from the overlay counts as Sleuth's own work, so
it is left out of the next frame's start delay. A second tap during a
copy is ignored.
- A dismissed debug-mode banner stays dismissed when the dashboard is
closed and reopened.

## 0.38.0

Expand Down
32 changes: 30 additions & 2 deletions doc/mcp_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"errors": ["arg_invalid_section", "arg_invalid_int", "arg_pagination_unused"],
"data": {
"_shape_source": "SessionSnapshot.toJson() in lib/src/models/session_snapshot.dart",
"_projection_note": "When any projection arg is set, a payload field is present only if its SnapshotSection is in _projectedSections. Metadata keys (schemaVersion, exportedAt, packageVersion, isVmConnected, isDebugMode, suppressedCount) always serialize.",
"_projection_note": "When any projection arg is set, a payload field is present only if its SnapshotSection is in _projectedSections. Metadata keys (schemaVersion, exportedAt, packageVersion, isVmConnected, isDebugMode, suppressedCount, notUsefulMarks) always serialize.",
"_projectedSections": {"type": "List<String>", "required": false, "nullable": false, "presence": "when any projection arg (sections, maxIssueCount or maxRouteCount) was set; holds the SnapshotSection keys in this payload, sorted alphabetically"},
"_projectionLimits": {"type": "Map", "required": false, "nullable": false, "presence": "when maxIssueCount or maxRouteCount was set", "shape": {"maxIssueCount": {"type": "int", "required": false, "nullable": false}, "maxRouteCount": {"type": "int", "required": false, "nullable": false}}},
"_projectionApplied": {"type": "String", "required": false, "nullable": false, "presence": "when any projection arg was set", "values": ["by_app", "by_sidecar_fallback"]},
Expand All @@ -92,6 +92,33 @@
"capturedFrames": {"type": "List<Map>", "required": true, "nullable": false, "presence": "always, unless sections leaves it out"},
"currentIssues": {"type": "List<Map>", "required": true, "nullable": false, "presence": "always, unless sections leaves it out"},
"suppressedCount": {"type": "int", "required": false, "nullable": false, "presence": "only when above 0"},
"notUsefulMarks": {
"type": "List<Map>",
"required": false,
"nullable": false,
"presence": "only when an issue was marked Not useful in the overlay this session",
"doc": "Session-only marks, oldest first, at most 200. Like suppressedCount it is written whatever the projection.",
"item_shape": {
"key": {"type": "String", "required": true, "nullable": false, "doc": "The overlay list key: stableId (else the title), plus |widgetName when the issue names a widget."},
"stableId": {"type": "String", "required": false, "nullable": false, "presence": "when the issue has one"},
"title": {"type": "String", "required": true, "nullable": false},
"category": {"type": "String", "required": true, "nullable": false, "values": ["build", "layout", "paint", "raster", "memory", "channel", "font", "network", "startup"]},
"severity": {"type": "String", "required": true, "nullable": false, "values": ["ok", "warning", "critical"]},
"confidence": {"type": "String", "required": true, "nullable": false, "values": ["confirmed", "likely", "possible"]},
"measurement": {"type": "Map", "required": false, "nullable": false, "presence": "when the issue had a measurement with a finite value", "doc": "The same shape as a currentIssues item's measurement key.", "shape": {
"value": {"type": "num", "required": true, "nullable": false},
"unit": {"type": "String", "required": true, "nullable": false, "doc": "milliseconds, seconds, percent, bytes, bytesPerSecond, count, perSecond, perMinute or frames. A reader keeps a unit it does not know by name."},
"warningAt": {"type": "num", "required": false, "nullable": false, "presence": "when the detector has a warning line"},
"criticalAt": {"type": "num", "required": false, "nullable": false, "presence": "when the detector has a critical line"},
"windowMs": {"type": "num", "required": false, "nullable": false, "presence": "when the value was counted over a window; a fraction keeps microseconds"},
"warningStrict": {"type": "bool", "required": false, "nullable": false, "value": true, "presence": "only with warningAt, when a value must exceed it"},
"criticalStrict": {"type": "bool", "required": false, "nullable": false, "value": true, "presence": "only with criticalAt, when a value must exceed it"}
}},
"route": {"type": "String", "required": false, "nullable": false, "presence": "when the issue carried a route"},
"markedAt": {"type": "String (ISO-8601)", "required": true, "nullable": false},
"live": {"type": "bool", "required": true, "nullable": false, "doc": "Whether the issue was still reported when the payload was built."}
}
},
"recentRequests": {"type": "List<Map>", "required": false, "nullable": false, "presence": "when NetworkMonitorDetector is enabled and the request ring buffer is non-empty"},
"heapSamples": {"type": "List<Map>", "required": false, "nullable": false, "presence": "when MemoryPressureDetector has at least one sample buffered"},
"phaseEvents": {"type": "List<Map>", "required": false, "nullable": false, "presence": "when the controller's rolling timeline-event buffer is non-empty"},
Expand Down Expand Up @@ -222,7 +249,8 @@
"data": {
"issues": {"type": "List<Map>", "required": true, "nullable": false, "item_shape": "PerformanceIssue.toJson()"},
"route": {"type": "String", "required": false, "nullable": false, "presence": "only when the route arg was non-empty"},
"vmConnected": {"type": "bool", "required": true, "nullable": false, "doc": "The same flag as ext.sleuth.diagnose data.vmConnected. It tells a VM-connected basic session from one without a VM link."}
"vmConnected": {"type": "bool", "required": true, "nullable": false, "doc": "The same flag as ext.sleuth.diagnose data.vmConnected. It tells a VM-connected basic session from one without a VM link."},
"notUsefulMarks": {"type": "List<Map>", "required": false, "nullable": false, "presence": "only when an issue was marked Not useful in the overlay this session", "doc": "Every mark, whatever the route arg.", "item_shape": "see ext.sleuth.snapshot.data.notUsefulMarks item_shape"}
}
},
"ext.sleuth.routeHealth": {
Expand Down
31 changes: 30 additions & 1 deletion doc/mcp_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ The handler returns `SessionSnapshot.toJson()`, defined in `lib/src/models/sessi
| `maxIssueCount` | String (int) | keeps the top N already-ranked `currentIssues`. A negative or non-numeric value returns `arg_invalid_int`. Setting it while `sections` leaves out `currentIssues` returns `arg_pagination_unused`. |
| `maxRouteCount` | String (int) | keeps the N most recent `routeSessions` by `startedAt`. It returns the same errors as `maxIssueCount`, with `routeSessions` in place of `currentIssues`. |

When any projection arg is set, a payload field is present only if its section is in `_projectedSections`. A cap without `sections` lists every section there. Metadata keys (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, `isDebugMode`, `suppressedCount`) always serialize.
When any projection arg is set, a payload field is present only if its section is in `_projectedSections`. A cap without `sections` lists every section there. Metadata keys (`schemaVersion`, `exportedAt`, `packageVersion`, `isVmConnected`, `isDebugMode`, `suppressedCount`, `notUsefulMarks`) always serialize.

| `data` key | Type | Required | Presence |
|---|---|---|---|
Expand All @@ -124,6 +124,7 @@ When any projection arg is set, a payload field is present only if its section i
| `capturedFrames` | List\<Map\> | yes | always, unless sections leaves it out |
| `currentIssues` | List\<Map\> | yes | always, unless sections leaves it out |
| `suppressedCount` | int | no | only when above 0 |
| `notUsefulMarks` | List\<Map\> | no | only when an issue was marked Not useful in the overlay this session. Session-only marks, oldest first, at most 200; item shape below. |
| `recentRequests` | List\<Map\> | no | when NetworkMonitorDetector is enabled and the request ring buffer is non-empty |
| `heapSamples` | List\<Map\> | no | when MemoryPressureDetector has at least one sample buffered |
| `phaseEvents` | List\<Map\> | no | when the controller's rolling timeline-event buffer is non-empty |
Expand All @@ -136,6 +137,33 @@ When any projection arg is set, a payload field is present only if its section i
| `startupMetrics` | Map | no | when `Sleuth.init` captured first-frame data |
| `routeSessions` | List\<Map\> | no | when the route history is not empty |

#### `notUsefulMarks[]` item shape

| Key | Type | Required | Presence |
|---|---|---|---|
| `key` | String | yes | always; the overlay list key: `stableId` (else the title), plus `\|widgetName` when the issue names a widget |
| `stableId` | String | no | when the issue has one |
| `title` | String | yes | always |
| `category`, `severity`, `confidence` | String | yes | always; the enum names |
| `measurement` | Map | no | when the issue had a measurement with a finite value; the same shape as a `currentIssues` item's `measurement`, below |
| `route` | String | no | when the issue carried a route |
| `markedAt` | String (ISO-8601) | yes | always |
| `live` | bool | yes | always; whether the issue was still reported when the payload was built |

#### Issue `measurement` sub-shape

An issue item (`currentIssues[]`, `ext.sleuth.issues` `issues[]`) carries `measurement` when its detector reported a number: the value it measured and its warning and critical lines. The issue's `severity` is the detector's and can disagree with where the value sits against the lines, in either direction.

| Key | Type | Required | Presence |
|---|---|---|---|
| `value` | num | yes | always |
| `unit` | String | yes | always; `milliseconds`, `seconds`, `percent`, `bytes`, `bytesPerSecond`, `count`, `perSecond`, `perMinute` or `frames`. A reader keeps a unit it does not know by name. |
| `warningAt` | num | no | when the detector has a warning line |
| `criticalAt` | num | no | when the detector has a critical line |
| `windowMs` | num | no | when the value was counted over a window; a fraction keeps microseconds |
| `warningStrict` | bool | no | only with `warningAt`, when a value must exceed it; always `true` when present |
| `criticalStrict` | bool | no | only with `criticalAt`, when a value must exceed it; always `true` when present |

#### `recurrenceTrends.<stableId>` sub-shape

| Key | Type | Required | Presence |
Expand Down Expand Up @@ -216,6 +244,7 @@ Returns the current aggregated issues. It takes an optional `route` (String). Wi
| `issues` | List\<Map\> | yes | always; each item is `PerformanceIssue.toJson()` |
| `route` | String | no | only when the route arg was non-empty |
| `vmConnected` | bool | yes | always; the same flag as `ext.sleuth.diagnose` `data.vmConnected`, which tells a VM-connected `basic` session from one without a VM link |
| `notUsefulMarks` | List\<Map\> | no | only when an issue was marked Not useful in the overlay this session; every mark, whatever the `route` arg. The item shape is `ext.sleuth.snapshot` `notUsefulMarks[]`. |

### `ext.sleuth.routeHealth`

Expand Down
3 changes: 2 additions & 1 deletion lib/sleuth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export 'src/models/frame_budget.dart';
export 'src/models/frame_verdict.dart';
export 'src/models/widget_highlight.dart';
export 'src/models/capture_buffer.dart';
export 'src/models/not_useful_mark.dart' show NotUsefulMark;
export 'src/models/session_snapshot.dart';
export 'src/models/snapshot_sections.dart';
export 'src/controller/sleuth_controller.dart' show SleuthConfig;
Expand Down Expand Up @@ -678,7 +679,7 @@ class Sleuth {
/// [resource].
static void trackResource(String name, Object resource) {
if (kReleaseMode) return;
_controller?.trackedResourceDetector?.track(name, resource);
_controller?.trackResource(name, resource);
}

/// Best-effort decrement: removes a registration whose target is
Expand Down
39 changes: 39 additions & 0 deletions lib/src/analyzer/jank_relation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import '../models/frame_verdict.dart';
import '../models/performance_issue.dart';

/// Whether [issue] reports late frames themselves rather than a cause.
bool isJankSymptom(PerformanceIssue issue) =>
issue.stableId == 'jank_detected' || issue.stableId == 'sustained_jank';

/// The issues in [issues] that can explain a frame that missed its
/// deadline in [phase].
///
/// A build, layout, paint or raster phase takes the issues of the same
/// category. A start delay is explained only by `event_loop_stall`.
/// `pipelineStall` and `unknown` name no phase work, so they take none.
/// Jank symptoms are never a cause.
List<PerformanceIssue> relatedToPhase(
PipelinePhase phase,
List<PerformanceIssue> issues,
) {
final category = switch (phase) {
PipelinePhase.build => IssueCategory.build,
PipelinePhase.layout => IssueCategory.layout,
PipelinePhase.paint => IssueCategory.paint,
PipelinePhase.raster => IssueCategory.raster,
PipelinePhase.schedulerDelay ||
PipelinePhase.pipelineStall ||
PipelinePhase.unknown => null,
};
if (phase == PipelinePhase.schedulerDelay) {
return [
for (final i in issues)
if (i.stableId == 'event_loop_stall') i,
];
}
if (category == null) return const [];
return [
for (final i in issues)
if (i.category == category && !isJankSymptom(i)) i,
];
}
14 changes: 9 additions & 5 deletions lib/src/analyzer/render_pipeline_analyzer.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'frame_event_correlator.dart';
import 'jank_relation.dart';
import '../models/frame_stats.dart';
import '../models/frame_verdict.dart';
import '../models/performance_issue.dart';
Expand Down Expand Up @@ -33,6 +34,7 @@ class RenderPipelineAnalyzer {
} else {
suspected = uiUs > rasterUs ? PipelinePhase.build : PipelinePhase.raster;
}
final related = relatedToPhase(suspected, relatedIssues);

_lastVerdict = FrameVerdict(
frameNumber: frameStats.frameNumber,
Expand All @@ -45,7 +47,7 @@ class RenderPipelineAnalyzer {
rasterThreadTime: frameStats.rasterDuration,
suspectedPhase: suspected,
reason: _buildBasicReason(frameStats),
relatedIssues: relatedIssues,
relatedIssues: related,
isFullMode: false,
);

Expand Down Expand Up @@ -128,6 +130,7 @@ class RenderPipelineAnalyzer {
PipelinePhase.layout: layoutUs,
PipelinePhase.paint: paintUs,
});
final related = relatedToPhase(suspected, relatedIssues);

// Build reason string
final reason = _buildFullReason(
Expand All @@ -137,7 +140,7 @@ class RenderPipelineAnalyzer {
paintUs: paintUs,
rasterUs: rasterUs,
frameStats: frameStats,
relatedIssues: relatedIssues,
relatedIssues: related,
);

_lastVerdict = FrameVerdict(
Expand All @@ -154,7 +157,7 @@ class RenderPipelineAnalyzer {
flushPaintTime: Duration(microseconds: paintUs),
suspectedPhase: suspected,
reason: reason,
relatedIssues: relatedIssues,
relatedIssues: related,
isFullMode: true,
);

Expand Down Expand Up @@ -184,6 +187,7 @@ class RenderPipelineAnalyzer {
PipelinePhase.layout: layoutUs,
PipelinePhase.paint: paintUs,
});
final related = relatedToPhase(suspected, relatedIssues);

final reason = _buildCorrelatedReason(
suspected: suspected,
Expand All @@ -193,7 +197,7 @@ class RenderPipelineAnalyzer {
rasterUs: rasterUs,
frameStats: frameStats,
correlation: correlation,
relatedIssues: relatedIssues,
relatedIssues: related,
);

_lastVerdict = FrameVerdict(
Expand All @@ -210,7 +214,7 @@ class RenderPipelineAnalyzer {
flushPaintTime: Duration(microseconds: paintUs),
suspectedPhase: suspected,
reason: reason,
relatedIssues: relatedIssues,
relatedIssues: related,
isFullMode: true,
isCorrelated: true,
correlationCoverage: correlation.batchCoverageRatio,
Expand Down
Loading
Loading