> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/safe-in-app-network-inspector.md).

# A Safe In-App Network Inspector

How I gate a network inspector at compile time, preserve HTTP transport, and retain only typed projections with safe memory and TTL limits

## Result

I still want to inspect requests inside an internal build, but I do not want to turn the app into a store of raw URLs, credentials, bodies, responses, and cURL commands. The final design therefore starts with build policy, not with a log screen.

```
compile-time gate defaults to false
              │
              ▼
idempotent app-level lifecycle owner
              │
              ▼
transport-preserving observer
  ├── forward request / abort / stream / close
  └── classifier + recorder cannot change the result
              │
              ▼
typed sanitizer before storage
  ├── NetworkOperation, no stored URI/path
  ├── protocol category, no stored header value
  └── body omitted or validated enum/count/Boolean
              │
              ▼
bounded in-memory store
  ├── max entries + per-entry bytes + total bytes
  └── monotonic deadline + clear on session
              │
              ▼
safe UI / copy / export
```

The intended result has six properties:

* A public release cannot enable the inspector through runtime config, a deep link, or a hidden gesture.
* Shake and keyboard triggers have exactly one owner, stop while the app is inactive, and dispose idempotently.
* A classifier, recorder, or observer-health counter failure cannot change the original HTTP transport response or exception.
* The store accepts only `NetworkOperation`, protocol categories, and sealed projections; it retains no raw route/path, header value, body, response, or cURL.
* Memory is bounded simultaneously by count, per-entry bytes, total bytes, and a TTL based on a monotonic clock.
* Copy/export accepts only a safe snapshot; cURL is disabled by default, and clipboard limitations are stated accurately for each platform.

This article applies to both Android and iOS. Capture, the type contract, and retention live in Dart; shake thresholds, hardware keyboards, and clipboard policy still require real-device verification on each platform.

The following qualifiers matter:

| Label               | What I actually know                                                                                                                      |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Source-verified** | I traced the current source from the inspector package through the composition root, shared HTTP boundary, call sites, and related tests. |
| **Configured**      | The build/runtime source contains a gate and values, but the result still depends on pipeline inputs.                                     |
| **Deduced**         | This is a reasonable consequence of the source and exact dependencies, not runtime evidence.                                              |
| **Proposed design** | This is the safe contract in the article; the reference source has not fully implemented it.                                              |
| **Test evidence**   | Only analyzer, test, and artifact checks that actually ran count as evidence.                                                             |
| **Runtime unknown** | Source alone does not establish device, backend, dashboard, or binary behavior.                                                           |

Source verification shows that the app already invokes an inspector from lifecycle code and routes requests through a shared service boundary. However, the current source still retains raw material and does not implement the retention, cancellation, and disposal contract in the public sample below. I therefore do not describe the proposed design as production behavior.

Every operation, label, route literal, projection field, budget, and sensitive marker in this article is a synthetic fixture. None is copied from an endpoint, payload, account, or traffic in the reference app.

## Problem

### A runtime gate may hide the UI without locking the feature

**Configured:** the current source decides whether to enable the inspector from runtime build metadata. The policy is an allowlist, but missing or unrecognized metadata can reach a broader default branch than intended.

**Deduced:** if a public binary still contains the listener, route, raw model, and copy action, a runtime check does not prove that the feature was removed. It also cannot prevent a configuration regression from enabling diagnostic code again.

A `const bool.fromEnvironment` that defaults to `false` is better because it gives the compiler an opportunity to treat the inspector branch as unreachable. But “an opportunity to eliminate dead code” is not the same as “proof that the binary contains no such code.” A source gate, an artifact, and physical-device behavior are three different kinds of evidence.

### An unclear lifecycle owner can duplicate listeners and retain stale state

**Source-verified:** the inspector is initialized after the first frame, registers a global hardware-keyboard handler, and auto-starts a shake detector. The source has repeated init paths but no idempotency guard covering every resource.

**Source-verified:** the keyboard handler uses a Tab-related key and returns `false`. Shake uses an aggressive opening threshold; the modal guard is not reset with `finally` around the complete navigation Future.

**Deduced:** repeated resume/init calls can create multiple subscriptions; a navigation error can leave the guard stuck; and a global shortcut can conflict with focus traversal. The source contains no widget or real-device tests that would turn these deductions into runtime facts.

### A count-only buffer does not bound memory

**Source-verified:** the current store caps the number of entries, but each entry can retain a full URI, request headers, request body, response, and cURL. The cURL command is built during capture, before the user asks to copy anything.

**Source-verified:** the UI time range filters only a snapshot; it does not remove entries from the controller. The clear button empties a view copy, so search or reopening the screen can bring old data back.

**Deduced:** a single large response can consume substantial heap even when the queue contains very few entries. A TTL based on `DateTime.now()` can also be affected when the wall clock jumps forward or backward.

| Control that looks safe       | Why it is insufficient                                  |
| ----------------------------- | ------------------------------------------------------- |
| At most N requests            | Does not cap bytes per entry or total heap              |
| Display only part of the body | The underlying model can still retain the full response |
| “Last 5 minutes” filter       | Old entries still remain in the canonical store         |
| Clear the list on the page    | The controller can repopulate the view                  |
| Use a wall-clock timestamp    | A system-time change can alter the retention decision   |

### A raw allowlist can still retain secrets

Allowlisting header names is not enough. `Content-Type` can carry parameters or a multipart boundary; `Accept` can contain vendor/profile values. A model such as `SafeHeader(name, value)` still lets an arbitrary string enter the store.

The body has the same problem. Reviewing a key does not make its value safe. If that value is an arbitrary string, it can still contain an email address, identifier, or secret. Truncating after capture does not repair this contract.

The public default must be stricter:

* Drop header values and retain only normalized typed categories.
* Omit the body by default.
* Let an optional operation-specific projector emit only validated enums, bounded counts, or Booleans.
* Map unknown, invalid, or truncated JSON to metadata-only output; never fall back to `toString()` or raw text.
* Do not let the store, UI, copy/export, or operational telemetry accept a generic `Map`, raw JSON tree, payload string, or `Object error`.

### The observer must not become a transport failure

**Source-verified:** the shared HTTP path currently does not expose a complete ownership contract for request abort and client close at the inspector layer. Status is also interpreted using one exact success code instead of a full status class.

A naive wrapper often does the following:

1. Classify the request.
2. Send the request.
3. Record the response or exception.

If the classifier throws before step 2, the request is never sent. If `recordResponse` throws inside the `try`, the outer `catch` can call `recordTransportFailure` and turn a successful response into a failure. If the recorder inside `catch` throws again, it masks the original `TimeoutException`, `RequestAbortedException`, or transport exception.

The inspector is an observer. Even if that observer fails, the caller must still receive the exact `StreamedResponse` instance or the exact original exception and stack.

### Source, test, and runtime evidence are not interchangeable

The current research has these limits:

* **Test evidence:** I read the complete inspector package, README/pubspec, composition root, shared service/client, build/config gate, and related call sites/tests.
* **Test evidence:** the exact dependencies checked were `http 1.6.0` and `shake 3.0.0`.
* **Test evidence:** a focused analyzer run was attempted but blocked because the local dependency cache lacked the shake package; this is neither an analyzer pass nor a production failure.
* **Artifact unknown:** no suitable public-release APK/AAB/IPA was available to prove that the inspector is absent.
* **Device unknown:** no physical Android/iOS negative trigger, shake-threshold, or hardware-keyboard matrix has run.
* **Memory unknown:** heap/GC and p50/p95/p99 capture overhead have not been profiled.
* **Clipboard unknown:** the native sensitive/local-only/expiration adapter in the reference app has not been verified.
* **Backend/dashboard unknown:** the inspector sees only the client path; it does not prove a server-side effect or telemetry delivery.

## Solution

### Gate at compile time and verify at three levels

**Proposed design:** the gate defaults to `false`, requires both an explicit request and an internal audience, and hard-disables release in this store-safe sample.

```dart
import 'package:flutter/foundation.dart';

const bool _inspectorRequested = bool.fromEnvironment(
  'ENABLE_NETWORK_INSPECTOR',
  defaultValue: false,
);

const String _buildAudience = String.fromEnvironment(
  'BUILD_AUDIENCE',
  defaultValue: 'public',
);

const bool networkInspectorCompiledIn =
    !kReleaseMode && _buildAudience == 'internal' && _inspectorRequested;

NetworkInspectorRuntime? createInspectorRuntime({
  required MonotonicClock monotonicClock,
}) {
  if (!networkInspectorCompiledIn) return null;

  final limits = InspectorLimits.tryCreate(
    maxEntries: 80,
    maxTotalBytes: 512 * 1024,
    maxEntryBytes: 32 * 1024,
    ttl: const Duration(minutes: 15),
  );
  if (limits == null) return null;

  return NetworkInspectorRuntime(
    limits: limits,
    monotonicClock: monotonicClock,
  );
}
```

The budgets are neutral fixtures. I measure synthetic payloads and profile heap before choosing real values; I do not copy them into a universal “best practice.”

A missing define, typo, unknown audience, or invalid limits all return `null`. A runtime asset, remote config, deep link, or gesture cannot change `false` to `true`.

```
const gate defaults to false
          │ source policy
          ▼
CI negative build assertion
          │ build policy
          ▼
artifact inspection
          │ binary evidence
          ▼
store-like physical-device negative trigger
          │ runtime evidence
          ▼
conclude only within the verified build scope
```

The artifact stage should inspect route/UI strings, symbols/reachable code, and dependency footprint, but one absent string does not prove that every code path was tree-shaken. The device stage must try shake, keyboard, deep-link, and runtime-config mutation on a store-like build. I call a release negative-verified only after build policy, artifact evidence, and black-box behavior all pass.

If the organization truly needs an internal AOT release, I use a separate entrypoint/audience and a separate test matrix. I do not relax the public default in this sample.

### Assign one idempotent lifecycle owner

One owner manages the trigger, launcher, and session store:

```
detached
   │ start once when the navigator is ready
   ▼
 active ── pause/hidden ──► suspended
   │                           │
   │ session end               └── resume ──► active
   ▼
 cleared
   │ app detach/dispose
   ▼
disposed
  ├── stop trigger
  ├── remove keyboard handler
  ├── close detail route
  └── clear store/timer/controller
```

```dart
import 'dart:async';

abstract interface class InspectorTrigger {
  void start(VoidCallback onTriggered);
  void stop();
}

abstract interface class InspectorLauncher {
  Future<void> open();
  void closeIfOpen();
}

abstract interface class InspectorStore {
  void clearSession();
}

typedef VoidCallback = void Function();

enum InspectorHealthCategory {
  triggerStart,
  triggerStop,
  launcherOpen,
  routeClose,
  storeClear,
}

abstract interface class InspectorHealthCounter {
  void increment(InspectorHealthCategory category);
}

final class InspectorLifecycleOwner {
  InspectorLifecycleOwner({
    required InspectorTrigger trigger,
    required InspectorLauncher launcher,
    required InspectorStore store,
    InspectorHealthCounter? healthCounter,
  }) : _trigger = trigger,
       _launcher = launcher,
       _store = store,
       _healthCounter = healthCounter;

  final InspectorTrigger _trigger;
  final InspectorLauncher _launcher;
  final InspectorStore _store;
  final InspectorHealthCounter? _healthCounter;

  bool _started = false;
  bool _active = false;
  bool _opening = false;
  bool _disposed = false;

  void start() {
    if (_disposed || _started) return;
    _started = true;
    resume();
  }

  void pause() {
    if (!_active) return;
    _active = false;
    _bestEffort(InspectorHealthCategory.triggerStop, _trigger.stop);
  }

  void resume() {
    if (_disposed || !_started || _active) return;
    _active = true;
    try {
      _trigger.start(_onTriggered);
    } catch (_) {
      _active = false;
      _recordHealth(InspectorHealthCategory.triggerStart);
    }
  }

  void _onTriggered() {
    unawaited(_openSafely());
  }

  Future<void> _openSafely() async {
    if (_disposed || !_active || _opening) return;
    _opening = true;
    try {
      await _launcher.open();
    } catch (_) {
      _recordHealth(InspectorHealthCategory.launcherOpen);
    } finally {
      _opening = false;
    }
  }

  void clearSession() {
    _bestEffort(InspectorHealthCategory.routeClose, _launcher.closeIfOpen);
    _bestEffort(InspectorHealthCategory.storeClear, _store.clearSession);
  }

  void dispose() {
    if (_disposed) return;
    _disposed = true;
    _active = false;
    _bestEffort(InspectorHealthCategory.triggerStop, _trigger.stop);
    clearSession();
  }

  void _bestEffort(InspectorHealthCategory category, VoidCallback action) {
    try {
      action();
    } catch (_) {
      _recordHealth(category);
    }
  }

  void _recordHealth(InspectorHealthCategory category) {
    final counter = _healthCounter;
    if (counter == null) return;
    try {
      counter.increment(category);
    } catch (_) {
      // Health instrumentation must not break lifecycle cleanup.
    }
  }
}
```

`InspectorTrigger` explicitly accepts a synchronous callback. `_onTriggered()` deliberately calls `unawaited`, but `_openSafely()` catches every synchronous or asynchronous launcher failure, records only `InspectorHealthCategory.launcherOpen`, and always resets `_opening` in `finally`. The counter receives no `Object`, message, stack, or raw route, and the counter itself cannot throw outward.

Cleanup uses independent `_bestEffort` boundaries. A throwing `trigger.stop()` cannot prevent route close, and a throwing route close cannot prevent store clear. State is marked disposed before cleanup, so a second dispose remains a no-op even if the first call encountered failures. A real Flutter adapter must also remove the exact `HardwareKeyboard` handler instance and dispose the `AppLifecycleListener`, controllers, timers, and subscriptions. The launcher should obtain navigator state when opening instead of retaining a long-lived `BuildContext`.

The keyboard shortcut needs a modifier and must not capture app-wide Tab focus traversal. Shake needs debounce/cooldown, and one burst must open only one route. This remains a proposed design until widget tests and the physical-device matrix pass.

### Wrap HTTP without changing its semantics

The HTTP client still owns authentication, retry, error mapping, cancellation, the response stream, and close. The inspector observes only typed metrics. The complete HTTP boundary is covered in NET-01:

{% content-ref url="/pages/r1BG7cXgqoay4tvlyEeO" %}
[Production HTTP Client for Flutter](/flutter/my-flutter/systems-realtime/production-http-client.md)
{% endcontent-ref %}

The sample below uses a synchronous `void` recorder only to enqueue safe metrics into a bounded local store. I do not attach an asynchronous uploader that could hang the request critical path; if asynchronous processing is required, I place it behind a bounded non-throwing queue.

```dart
import 'dart:async';

import 'package:http/http.dart' as http;

enum NetworkOperation { unknown, listItems, submitForm }

enum HttpVerb { get, head, post, put, patch, delete, options, other }

enum ObserverFailureStage {
  classify,
  response,
  cancelled,
  timeout,
  transportFailure,
}

abstract interface class OperationClassifier {
  NetworkOperation classify(HttpVerb method, Uri uri);
}

abstract interface class NetworkMetricRecorder {
  void recordResponse({
    required NetworkOperation operation,
    required HttpVerb method,
    required int statusCode,
    required Duration elapsed,
  });

  void recordCancelled({
    required NetworkOperation operation,
    required HttpVerb method,
    required Duration elapsed,
  });

  void recordTimeout({
    required NetworkOperation operation,
    required HttpVerb method,
    required Duration elapsed,
  });

  void recordTransportFailure({
    required NetworkOperation operation,
    required HttpVerb method,
    required Duration elapsed,
  });
}

abstract interface class ObserverFailureCounter {
  void increment(ObserverFailureStage stage);
}

HttpVerb classifyVerb(String value) => switch (value.toUpperCase()) {
  'GET' => HttpVerb.get,
  'HEAD' => HttpVerb.head,
  'POST' => HttpVerb.post,
  'PUT' => HttpVerb.put,
  'PATCH' => HttpVerb.patch,
  'DELETE' => HttpVerb.delete,
  'OPTIONS' => HttpVerb.options,
  _ => HttpVerb.other,
};

final class InspectableClient extends http.BaseClient {
  InspectableClient({
    required http.Client inner,
    required NetworkMetricRecorder recorder,
    required OperationClassifier classifier,
    ObserverFailureCounter? observerFailures,
  }) : _inner = inner,
       _recorder = recorder,
       _classifier = classifier,
       _observerFailures = observerFailures;

  final http.Client _inner;
  final NetworkMetricRecorder _recorder;
  final OperationClassifier _classifier;
  final ObserverFailureCounter? _observerFailures;
  bool _closed = false;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
    final timer = Stopwatch()..start();
    final method = classifyVerb(request.method);
    final operation = _safeClassify(method, request.url);

    try {
      final response = await _inner.send(request);
      _safeObserve(
        ObserverFailureStage.response,
        () => _recorder.recordResponse(
          operation: operation,
          method: method,
          statusCode: response.statusCode,
          elapsed: timer.elapsed,
        ),
      );
      return response;
    } on http.RequestAbortedException {
      _safeObserve(
        ObserverFailureStage.cancelled,
        () => _recorder.recordCancelled(
          operation: operation,
          method: method,
          elapsed: timer.elapsed,
        ),
      );
      rethrow;
    } on TimeoutException {
      _safeObserve(
        ObserverFailureStage.timeout,
        () => _recorder.recordTimeout(
          operation: operation,
          method: method,
          elapsed: timer.elapsed,
        ),
      );
      rethrow;
    } catch (_) {
      _safeObserve(
        ObserverFailureStage.transportFailure,
        () => _recorder.recordTransportFailure(
          operation: operation,
          method: method,
          elapsed: timer.elapsed,
        ),
      );
      rethrow;
    }
  }

  NetworkOperation _safeClassify(HttpVerb method, Uri uri) {
    try {
      return _classifier.classify(method, uri);
    } catch (_) {
      _countObserverFailure(ObserverFailureStage.classify);
      return NetworkOperation.unknown;
    }
  }

  void _safeObserve(ObserverFailureStage stage, void Function() observe) {
    try {
      observe();
    } catch (_) {
      _countObserverFailure(stage);
    }
  }

  void _countObserverFailure(ObserverFailureStage stage) {
    final counter = _observerFailures;
    if (counter == null) return;
    try {
      counter.increment(stage);
    } catch (_) {
      // Observer health must not enter the transport critical path.
    }
  }

  @override
  void close() {
    if (_closed) return;
    _closed = true;
    _inner.close();
  }
}
```

The classifier is the only implementation-private boundary that needs to inspect a URI before mapping it to an enum. It returns only `NetworkOperation`; a throw or non-match becomes `unknown`. The raw URI/path never reaches the recorder, store, UI, copy, or export.

`_safeObserve` prevents `recordResponse` from falling into the outer transport catch. Every exception branch uses `rethrow`, so the caller receives the original exception and stack. The counter accepts only a fixed `ObserverFailureStage`, never a raw exception, message, stack, URI, or payload; the counter itself is also contained by the failure boundary.

The wrapper does not read the request body or consume `StreamedResponse.stream`. `AbortableRequest`, or an equivalent transport abstraction, continues to own cancellation. `close()` forwards directly exactly once; if inner close throws, the caller receives that exact failure and a later call does not close again.

### Store only typed operations, categories, and projections

Operational telemetry and the inspector store use two typed outputs rather than sharing one raw request model. OBS-01 covers Sentry ownership, sanitization, and delivery evidence:

{% content-ref url="/pages/ATOHUDA9JCzn2wmCbZD9" %}
[Sentry for Crashes and Tracing](/flutter/my-flutter/security-observability/sentry-flutter-crash-http-navigation-tracing.md)
{% endcontent-ref %}

```dart
enum NetworkOperation { unknown, listItems, submitForm }

enum HttpVerb { get, head, post, put, patch, delete, options, other }

enum NetworkOutcome { response, timeout, cancelled, transportFailure }

enum HttpStatusClass {
  none,
  informational,
  successful,
  redirection,
  clientError,
  serverError,
  invalid,
}

enum MediaTypeCategory { omitted, json, text, form, binary, other }

enum ContentEncodingCategory { omitted, identity, gzip, deflate, brotli, other }

enum PayloadOmissionReason {
  defaultPolicy,
  unsupportedMediaType,
  invalid,
  truncated,
}

enum SubmitResultKind { accepted, rejected, pending }

final class SafeNetworkMetric {
  const SafeNetworkMetric({
    required this.operation,
    required this.method,
    required this.outcome,
    required this.statusClass,
    required this.elapsed,
    required this.requestBytes,
    required this.responseBytes,
    this.statusCode,
  });

  final NetworkOperation operation;
  final HttpVerb method;
  final NetworkOutcome outcome;
  final HttpStatusClass statusClass;
  final Duration elapsed;
  final int requestBytes;
  final int responseBytes;
  final int? statusCode;
}

final class SafeProtocolMetadata {
  const SafeProtocolMetadata({
    this.requestMediaType = MediaTypeCategory.omitted,
    this.responseMediaType = MediaTypeCategory.omitted,
    this.responseEncoding = ContentEncodingCategory.omitted,
  });

  final MediaTypeCategory requestMediaType;
  final MediaTypeCategory responseMediaType;
  final ContentEncodingCategory responseEncoding;
}

sealed class SafePayloadProjection {
  const SafePayloadProjection();
}

const SafePayloadProjection defaultPayloadProjection = OmittedPayload(
  PayloadOmissionReason.defaultPolicy,
);

final class OmittedPayload extends SafePayloadProjection {
  const OmittedPayload(this.reason);

  final PayloadOmissionReason reason;
}

final class ItemListProjection extends SafePayloadProjection {
  const ItemListProjection._({required this.itemCount, required this.hasMore});

  final int itemCount;
  final bool hasMore;
}

final class SubmitFormProjection extends SafePayloadProjection {
  const SubmitFormProjection._({required this.result, required this.retryable});

  final SubmitResultKind result;
  final bool retryable;
}

final class SafeNetworkEntry {
  const SafeNetworkEntry._({
    required this.metric,
    required this.protocol,
    required this.requestProjection,
    required this.responseProjection,
    required this.retentionDeadline,
    required this.retainedBytes,
    this.displayCapturedAt,
  });

  final SafeNetworkMetric metric;
  final SafeProtocolMetadata protocol;
  final SafePayloadProjection requestProjection;
  final SafePayloadProjection responseProjection;
  final Duration retentionDeadline;
  final int retainedBytes;
  final DateTime? displayCapturedAt;
}

SafePayloadProjection projectItemList({
  required int? itemCount,
  required bool? hasMore,
}) {
  if (itemCount == null ||
      itemCount < 0 ||
      itemCount > 10000 ||
      hasMore == null) {
    return const OmittedPayload(PayloadOmissionReason.invalid);
  }
  return ItemListProjection._(itemCount: itemCount, hasMore: hasMore);
}

String renderOperationRoute(NetworkOperation operation) => switch (operation) {
  NetworkOperation.unknown => '<unknown>',
  NetworkOperation.listItems => '/items/{item}',
  NetworkOperation.submitForm => '/forms',
};

String renderProjection(SafePayloadProjection projection) =>
    switch (projection) {
      OmittedPayload(:final reason) => '<omitted:${reason.name}>',
      ItemListProjection(:final itemCount, :final hasMore) =>
        'itemCount=$itemCount;hasMore=$hasMore',
      SubmitFormProjection(:final result, :final retryable) =>
        'result=${result.name};retryable=$retryable',
    };

HttpStatusClass classifyStatus(int? code) {
  if (code == null) return HttpStatusClass.none;
  return switch (code ~/ 100) {
    1 => HttpStatusClass.informational,
    2 => HttpStatusClass.successful,
    3 => HttpStatusClass.redirection,
    4 => HttpStatusClass.clientError,
    5 => HttpStatusClass.serverError,
    _ => HttpStatusClass.invalid,
  };
}
```

The important part is not the enum names but the types that do not exist:

* There is no `routeTemplate: String` in an entry. A display route is rendered only from an exhaustive switch/app-owned registry. `unknown` always becomes a fixed literal and never interpolates the input path.
* There is no `SafeHeader(name, value)`. Raw `Content-Type` parameters, multipart boundaries, `Accept` vendor/profile values, and encoding strings are dropped; only typed categories remain.
* There is no `SafeTextPreview(String)`, generic `Map`, or raw JSON tree. Concrete projection constructors are private, and projectors validate type/range before creating an object.
* The body defaults to `OmittedPayload(defaultPolicy)`. An operation-specific decoder can inspect only bounded bytes in a short-lived scope and emit only an enum/count/Boolean.
* Unknown, invalid, or truncated JSON returns `OmittedPayload`; there is no raw fallback.
* `SafeNetworkMetric` has no `Object error`, URI, header/body/response, or cURL. If an error taxonomy is needed, add a reviewed fixed enum.

Status and outcome are separate axes. `201` and `204` both belong to `2xx successful`; `3xx`, `4xx`, and `5xx` are distinct classes. Timeout, cancellation, and transport failure do not receive a fake status.

### Validate limits in release and use monotonic deadlines

`assert` is useful only in debug. If a public constructor relies on `assert(maxEntries > 0)`, a release can still receive zero, a negative value, or `maxEntryBytes > maxTotalBytes`.

```dart
import 'dart:convert';

abstract interface class MonotonicClock {
  Duration get elapsed;
}

final class StopwatchMonotonicClock implements MonotonicClock {
  StopwatchMonotonicClock() : _stopwatch = Stopwatch()..start();

  final Stopwatch _stopwatch;

  @override
  Duration get elapsed => _stopwatch.elapsed;
}

final class InspectorLimits {
  const InspectorLimits._({
    required this.maxEntries,
    required this.maxTotalBytes,
    required this.maxEntryBytes,
    required this.ttl,
  });

  static InspectorLimits? tryCreate({
    required int maxEntries,
    required int maxTotalBytes,
    required int maxEntryBytes,
    required Duration ttl,
  }) {
    if (maxEntries <= 0 ||
        maxTotalBytes <= 0 ||
        maxEntryBytes <= 0 ||
        maxEntryBytes > maxTotalBytes ||
        ttl.inMicroseconds <= 0) {
      return null;
    }
    return InspectorLimits._(
      maxEntries: maxEntries,
      maxTotalBytes: maxTotalBytes,
      maxEntryBytes: maxEntryBytes,
      ttl: ttl,
    );
  }

  final int maxEntries;
  final int maxTotalBytes;
  final int maxEntryBytes;
  final Duration ttl;
}

final class _CanonicalSafeEntryCodec {
  const _CanonicalSafeEntryCodec();

  List<int> encode({
    required SafeNetworkMetric metric,
    required SafeProtocolMetadata protocol,
    required SafePayloadProjection requestProjection,
    required SafePayloadProjection responseProjection,
    required Duration retentionDeadline,
    required DateTime? displayCapturedAt,
  }) {
    final canonical = StringBuffer()
      ..writeln('operation=${_operationLabel(metric.operation)}')
      ..writeln('method=${metric.method.name}')
      ..writeln('outcome=${metric.outcome.name}')
      ..writeln('statusClass=${metric.statusClass.name}')
      ..writeln('statusCode=${metric.statusCode ?? 'none'}')
      ..writeln('elapsedUs=${metric.elapsed.inMicroseconds}')
      ..writeln('requestBytes=${metric.requestBytes}')
      ..writeln('responseBytes=${metric.responseBytes}')
      ..writeln('requestMedia=${protocol.requestMediaType.name}')
      ..writeln('responseMedia=${protocol.responseMediaType.name}')
      ..writeln('responseEncoding=${protocol.responseEncoding.name}')
      ..writeln('request=${renderProjection(requestProjection)}')
      ..writeln('response=${renderProjection(responseProjection)}')
      ..writeln('deadlineUs=${retentionDeadline.inMicroseconds}')
      ..write(
        'displayCapturedAt=${displayCapturedAt?.toUtc().toIso8601String() ?? 'omitted'}',
      );
    return utf8.encode(canonical.toString());
  }

  String _operationLabel(NetworkOperation operation) => switch (operation) {
    NetworkOperation.unknown => 'Unknown operation',
    NetworkOperation.listItems => 'List items',
    NetworkOperation.submitForm => 'Submit form',
  };
}

final class BoundedEntryStore {
  BoundedEntryStore({
    required InspectorLimits limits,
    required MonotonicClock clock,
  }) : _limits = limits,
       _clock = clock;

  final InspectorLimits _limits;
  final MonotonicClock _clock;
  final _codec = const _CanonicalSafeEntryCodec();
  final List<SafeNetworkEntry> _entries = [];
  int _retainedBytes = 0;

  void add({
    required SafeNetworkMetric metric,
    required SafeProtocolMetadata protocol,
    required SafePayloadProjection requestProjection,
    required SafePayloadProjection responseProjection,
    DateTime? displayCapturedAt,
  }) {
    final now = _clock.elapsed;
    _purgeExpired(now);

    final retentionDeadline = now + _limits.ttl;
    final encoded = _codec.encode(
      metric: metric,
      protocol: protocol,
      requestProjection: requestProjection,
      responseProjection: responseProjection,
      retentionDeadline: retentionDeadline,
      displayCapturedAt: displayCapturedAt,
    );
    final retainedBytes = encoded.length;

    if (retainedBytes <= 0) return;
    if (retainedBytes > _limits.maxEntryBytes) return;
    if (retainedBytes > _limits.maxTotalBytes) return;

    final entry = SafeNetworkEntry._(
      metric: metric,
      protocol: protocol,
      requestProjection: requestProjection,
      responseProjection: responseProjection,
      retentionDeadline: retentionDeadline,
      retainedBytes: retainedBytes,
      displayCapturedAt: displayCapturedAt,
    );

    _entries.add(entry);
    _retainedBytes += entry.retainedBytes;

    while (_entries.length > _limits.maxEntries ||
        _retainedBytes > _limits.maxTotalBytes) {
      final oldest = _entries.removeAt(0);
      _retainedBytes -= oldest.retainedBytes;
    }
  }

  List<SafeNetworkEntry> snapshot() {
    _purgeExpired(_clock.elapsed);
    return List.unmodifiable(_entries);
  }

  void _purgeExpired(Duration now) {
    _entries.removeWhere((entry) {
      final expired = entry.retentionDeadline.compareTo(now) <= 0;
      if (expired) _retainedBytes -= entry.retainedBytes;
      return expired;
    });
  }

  void clearSession() {
    _entries.clear();
    _retainedBytes = 0;
  }
}
```

The store owns entry creation with `retentionDeadline = clock.elapsed + limits.ttl`; the caller cannot provide a wall-clock expiry. `add()` also accepts no `retainedBytes`, serialized string, or raw payload. The private codec accepts only typed operations/metrics/categories/projections, renders them through a fixed exhaustive registry, and measures `utf8.encode(canonical).length`. Because the caller cannot inject a codec/sizer or estimate, it cannot under-report bytes to exceed the per-entry or total cap.

The abbreviated sample assumes the entry and codec live in the same sanitizer/store library with private constructors. On add/read/export, the store reads monotonic `now` and purges first. If export uses canonical bytes, it must call the same private codec instead of serializing a different raw model.

If retained, `displayCapturedAt` is for UI only. It does not participate in expiry, eviction order, or export decisions. A forward or backward wall-clock jump cannot lengthen or shorten retention. A monotonic origin has no meaning across processes, so the store does not persist entries across an app restart.

Byte accounting uses the safe entry's canonical UTF-8 representation, not `String.length`, and excludes raw payload that was already dropped. A fixed label containing multibyte characters is still counted using its actual encoded bytes. If an entry does not fit after projection, policy drops the entry or keeps metric-only metadata; it never retains part of a raw body.

The store invariants must hold after every add/read/export:

| Invariant | Assertion                                          |
| --------- | -------------------------------------------------- |
| Per-entry | `entry.retainedBytes <= maxEntryBytes`             |
| Count     | `entries.length <= maxEntries`                     |
| Total     | `sum(retainedBytes) <= maxTotalBytes`              |
| TTL       | `retentionDeadline > monotonic now`                |
| Session   | No entries from the previous logout/account remain |

### Copy/export only safe snapshots

The UI subscribes to the canonical store, while search/filter is only a derived view. Clear calls the store owner; if a TTL or session clear removes the currently open entry, detail closes or changes to an expired state instead of retaining a stale reference.

```dart
final class SafeCopySummary {
  const SafeCopySummary({
    required this.operation,
    required this.method,
    required this.statusClass,
    required this.elapsed,
    required this.requestProjection,
    required this.responseProjection,
  });

  final NetworkOperation operation;
  final HttpVerb method;
  final HttpStatusClass statusClass;
  final Duration elapsed;
  final SafePayloadProjection requestProjection;
  final SafePayloadProjection responseProjection;
}

SafeCopySummary toCopySummary(SafeNetworkEntry entry) {
  final metric = entry.metric;
  return SafeCopySummary(
    operation: metric.operation,
    method: metric.method,
    statusClass: metric.statusClass,
    elapsed: metric.elapsed,
    requestProjection: entry.requestProjection,
    responseProjection: entry.responseProjection,
  );
}

abstract interface class SafeCopyPort {
  Future<void> copy(SafeCopySummary summary);
}
```

`SafeCopyPort` accepts no request, response, URI, header map, JSON payload, or cURL string. The summary renderer uses only a fixed operation label, status, duration, and sealed projections.

cURL is disabled by default. If an internal policy genuinely requires it, the action generates it lazily from a fixed route literal and a typed recipe keyed by `NetworkOperation`; it does not read a raw URI/header/body or projection text to reconstruct a payload. Shell arguments must use a tested encoder rather than manual quote concatenation.

Clipboard and sharing are platform boundaries:

| Platform/boundary | Accurate policy                                                                                                                            |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Android           | Use a sensitive hint and clear-if-unchanged when a native adapter supports them; the hint mainly hides previews and is not access control. |
| iOS               | Consider local-only and expiration through a native adapter; Flutter's plain-text API does not expose every option by itself.              |
| Share target      | An external app can create a copy that the source app cannot revoke.                                                                       |
| Temporary export  | Place it in cache, enforce byte/count/TTL caps, and best-effort delete it after sharing or session end.                                    |
| Screenshot/golden | Use synthetic fixtures only; never capture real traffic, a terminal, or a dashboard.                                                       |

If native policy cannot be satisfied, disabling copy/export is safer than claiming that the clipboard is “automatically safe.”

### Test failure boundaries, not only the happy path

```dart
void verifyLimitBoundaries() {
  assert(
    InspectorLimits.tryCreate(
          maxEntries: 0,
          maxTotalBytes: 1024,
          maxEntryBytes: 128,
          ttl: const Duration(minutes: 1),
        ) ==
        null,
  );
  assert(
    InspectorLimits.tryCreate(
          maxEntries: 1,
          maxTotalBytes: 128,
          maxEntryBytes: 256,
          ttl: const Duration(minutes: 1),
        ) ==
        null,
  );
  assert(
    InspectorLimits.tryCreate(
          maxEntries: 1,
          maxTotalBytes: 128,
          maxEntryBytes: 128,
          ttl: Duration.zero,
        ) ==
        null,
  );
}

final class FakeMonotonicClock implements MonotonicClock {
  @override
  Duration elapsed = Duration.zero;

  void advance(Duration delta) {
    elapsed += delta;
  }
}
```

This is an abbreviated fixture. Real tests do not use `assert` as production validation; they call the factory and the test framework's matcher. The factory is what fails closed in release.

The minimum matrix is:

| Group             | Required case                                                                                                                                                               |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Compile gate      | A missing define, request flag only, internal audience only, and a store-like release are all disabled                                                                      |
| Artifact/device   | Combine artifact scanning with negative shake/key/deep-link/runtime-config tests on a physical build                                                                        |
| Lifecycle open    | The launcher throws synchronously/asynchronously: `_opening` resets, no error is unhandled, only the fixed health category is recorded, and the next trigger can still open |
| Lifecycle cleanup | `trigger.stop`, route close, and store clear each throw: every later step is still attempted, and a second dispose is a no-op                                               |
| Classifier        | Throw on success/failure: the request is still sent, operation is `unknown`, and no raw URI/path is retained                                                                |
| Recorder          | Each of `recordResponse`, `recordCancelled`, `recordTimeout`, and `recordTransportFailure` throws without changing the original response/exception                          |
| Counter           | An observer-health counter throw still cannot change the transport result                                                                                                   |
| Close/cancel      | Inner close occurs exactly once; its original close failure is retained; abort/stream semantics are unchanged                                                               |
| Protocol          | Media parameters/boundary/vendor markers do not appear in store/UI/copy/export                                                                                              |
| Projection        | Body defaults to omitted; an allowed key containing a sensitive marker still does not retain an arbitrary string                                                            |
| UTF-8 bytes       | A multibyte fixed safe label is measured with `utf8.encode(canonical).length`, not code units                                                                               |
| Forged estimate   | Public `add()` has no estimate/serialized-string parameter; a caller cannot under-report to bypass a cap                                                                    |
| Store caps        | Per-entry, total, count, and repeated-eviction cases all preserve the invariants                                                                                            |
| Limits            | Zero/negative count, bytes, or TTL and entry > total are all rejected by the factory in release                                                                             |
| Retention         | Before/at/after deadline; wall clock jumps while monotonic time stands still; monotonic time crosses the deadline                                                           |
| UI/export         | Canonical clear, expired detail, bounded copy, temporary cleanup, and the external-share caveat                                                                             |
| Performance       | Bursty concurrent requests, heap/GC, and p50/p95/p99 overhead using synthetic payloads                                                                                      |

The adversarial projection test must place a sensitive marker in the value of a seemingly allowed key. Assertions search for that marker in the store, UI renderer, copy summary, and export output; none may contain it. Unknown/truncated JSON must remain metadata-only and never switch to a raw fallback.

The lifecycle test must install a zone-level unhandled-error collector: a launcher failure on the first trigger creates no uncaught asynchronous error, increments only `launcherOpen`, and a second trigger still calls the launcher successfully. The cleanup test uses three independent throwing fakes and verifies that stop, close, and clear are all attempted in order despite an earlier failure; a throwing health counter must not interrupt the cleanup chain either.

The byte test supplies no “estimate.” It sends an operation with a fixed Unicode label through public `add()`, compares caps immediately below/above the actual canonical UTF-8 length, and verifies the invariants. A compile-time API check ensures that no named argument such as `retainedBytes` or serialized payload exists.

### Limits and release checklist

I do not use source to claim what source cannot prove:

* A `const` gate only creates an unreachable-path opportunity; binary absence requires artifact evidence.
* Artifact scanning does not replace a physical-device negative trigger.
* Count/byte/TTL unit tests do not replace heap profiling and GC measurement.
* A caller that stops listening to a Future does not prove that the socket/request was aborted.
* A response observed by the client does not prove that a backend transaction completed.
* A telemetry capture call does not prove that an envelope reached a dashboard; see the evidence ladder in OBS-01.
* Clipboard clear/expiration is best effort and cannot revoke an external copy.
* An analyzer blocked by a local cache is neither an analyzer pass nor a runtime failure.

The research snapshot used local Flutter SDK `3.47.1`, `http 1.6.0`, and `shake 3.0.0` on `2026-09-02`. This version set is the article's evidence boundary, not a behavior guarantee for newer dependencies.

Checklist before putting the article/sample code into a release workflow:

* The compile-time default is false, and the store pipeline has a negative assertion.
* The inspector runtime is not constructed when the gate or limits fail.
* The trigger callback is a synchronous adapter; launcher failure is contained as a fixed health category and creates no unhandled asynchronous error.
* Lifecycle cleanup always attempts each step; health instrumentation cannot block stop/close/clear.
* The store contract contains no raw route/path/header/body/response/cURL string.
* The store encodes canonical safe fields and computes UTF-8 bytes itself; public `add()` accepts no caller estimate or serialized payload.
* Classifier/recorder/counter failures do not change the exact response/original exception.
* Cancellation, streams, and close remain owned by transport.
* Count, byte, per-entry, and monotonic TTL invariants all have tests.
* Session clear removes store entries, detail state, search snapshots, and temporary export references.
* Copy/export accepts only a safe typed snapshot.
* Physical Android/iOS tests use synthetic fixtures and never a production backend.
* Public-secret scanning covers prose, code, tables, diagrams, alt text, and fixtures.

### References

* [Dart compilation environment declarations](https://dart.dev/libraries/core/environment-declarations)
* [Flutter HardwareKeyboard.addHandler](https://api.flutter.dev/flutter/services/HardwareKeyboard/addHandler.html)
* [Flutter AppLifecycleListener](https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html)
* [Dart http package](https://pub.dev/packages/http)
* [Flutter shake package](https://pub.dev/packages/shake)
* [RFC 9110 — HTTP status codes](https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes)
* [Dart Utf8Encoder](https://api.dart.dev/dart-convert/Utf8Encoder-class.html)
* [Dart Stopwatch](https://api.dart.dev/dart-core/Stopwatch-class.html)
* [OWASP MASTG — Disable verbose logging in production](https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0022/)
* [Android secure clipboard handling](https://developer.android.com/privacy-and-security/risks/secure-clipboard-handling)
* [Apple UIPasteboard](https://developer.apple.com/documentation/uikit/uipasteboard)
* [Flutter Clipboard.setData](https://api.flutter.dev/flutter/services/Clipboard/setData.html)

## Conclusion

A safe network inspector is not a well-hidden log screen. It is a chain of fail-closed contracts: a public build does not construct the runtime, lifecycle has one owner, the observer does not change transport semantics, the sanitizer emits only safe types, count/byte/monotonic TTL bounds the store, and every copy/export path sees only a safe snapshot.

The reference source exposes real failure modes: a runtime gate is not artifact proof, listener ownership is incomplete, a count-only buffer can still retain large raw material, a time filter is not a TTL, and instrumentation can change an exception when placed at the wrong boundary. The proposed design addresses those failures, but it becomes runtime fact only after the corresponding analyzer/tests, artifact inspection, memory profile, and physical-device verification.

If I keep one principle, it is this: raw data should not be captured first and hidden later by the UI. Make it impossible for store, telemetry, and copy APIs to accept raw data in the type contract itself.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
