flutter_api_observability 1.1.0 copy "flutter_api_observability: ^1.1.0" to clipboard
flutter_api_observability: ^1.1.0 copied to clipboard

A production-grade Flutter API Observability Toolkit providing real-time request history, endpoint performance statistics, sensitive data redaction, error normalization, and health reporting for Flutt [...]

example/lib/main.dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_observability/flutter_api_observability.dart';

void main() {
  runApp(const ApiGuardObservabilityApp());
}

class ApiGuardObservabilityApp extends StatelessWidget {
  const ApiGuardObservabilityApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter ApiGuard Observability Toolkit',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6366F1),
          brightness: Brightness.dark,
        ),
      ),
      home: const ObservabilityDashboardScreen(),
    );
  }
}

class ObservabilityDashboardScreen extends StatefulWidget {
  const ObservabilityDashboardScreen({super.key});

  @override
  State<ObservabilityDashboardScreen> createState() =>
      _ObservabilityDashboardScreenState();
}

class _ObservabilityDashboardScreenState
    extends State<ObservabilityDashboardScreen>
    with SingleTickerProviderStateMixin {
  late final ApiGuard _apiGuard;
  late final MockHttpClientAdapter _mockAdapter;
  late final TabController _tabController;
  final List<String> _liveLogs = [];
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
    _initApiGuard();
  }

  void _initApiGuard() {
    final dio = Dio();
    _mockAdapter = MockHttpClientAdapter();
    dio.httpClientAdapter = _mockAdapter;

    _apiGuard = ApiGuard(
      baseUrl: 'https://api.example.com',
      config: ApiGuardConfig(
        enableLogging: true,
        maxRetries: 2,
        retryDelay: const Duration(milliseconds: 200),
        slowRequestThreshold: const Duration(milliseconds: 500),
        sensitiveKeys: {
          'password',
          'token',
          'authorization',
          'secret',
          'cookie'
        },
        logger: ConsoleApiGuardLogger(
          printer: (line) {
            setState(() {
              _liveLogs.add(line);
            });
          },
        ),
      ),
      dio: dio,
    );
  }

  void _triggerSuccessRequest() async {
    setState(() => _isLoading = true);
    _mockAdapter.handler = (options) {
      return ResponseBody.fromString(
        jsonEncode([
          {'id': 101, 'name': 'MacBook Pro M3'},
          {'id': 102, 'name': 'iPhone 15 Pro'},
        ]),
        200,
        headers: {
          Headers.contentTypeHeader: [Headers.jsonContentType],
        },
      );
    };

    try {
      await _apiGuard.get<dynamic>('/products');
    } catch (_) {}
    setState(() => _isLoading = false);
  }

  void _triggerRedactedPostRequest() async {
    setState(() => _isLoading = true);
    _mockAdapter.handler = (options) {
      return ResponseBody.fromString(
        jsonEncode({
          'status': 'authenticated',
          'token': 'super_secret_jwt_bearer_token_xyz999',
          'cookie': 'session_id=abcdef12345',
        }),
        200,
        headers: {
          Headers.contentTypeHeader: [Headers.jsonContentType],
        },
      );
    };

    try {
      await _apiGuard.post<dynamic>(
        '/auth/login',
        headers: {'Authorization': 'Bearer raw_secret_bearer_token'},
        data: {
          'email': 'developer@flutter.dev',
          'password': 'my_super_secret_password',
        },
      );
    } catch (_) {}
    setState(() => _isLoading = false);
  }

  void _triggerUnauthorizedError() async {
    setState(() => _isLoading = true);
    _mockAdapter.handler = (options) {
      return ResponseBody.fromString(
        jsonEncode({'message': 'Access token expired or unauthorized'}),
        401,
        headers: {
          Headers.contentTypeHeader: [Headers.jsonContentType],
        },
      );
    };

    try {
      await _apiGuard.get<dynamic>('/user/profile');
    } catch (_) {}
    setState(() => _isLoading = false);
  }

  void _triggerServerErrorRetry() async {
    setState(() => _isLoading = true);
    _mockAdapter.handler = (options) {
      return ResponseBody.fromString(
        jsonEncode({'error': 'Internal Service Failure'}),
        500,
        headers: {
          Headers.contentTypeHeader: [Headers.jsonContentType],
        },
      );
    };

    try {
      await _apiGuard.get<dynamic>('/orders/checkout');
    } catch (_) {}
    setState(() => _isLoading = false);
  }

  void _triggerSlowRequest() async {
    setState(() => _isLoading = true);
    _mockAdapter.handler = (options) {
      return ResponseBody.fromString(
        jsonEncode({'status': 'report_generated'}),
        200,
        headers: {
          Headers.contentTypeHeader: [Headers.jsonContentType],
        },
      );
    };

    await Future<void>.delayed(const Duration(milliseconds: 700));
    try {
      await _apiGuard.get<dynamic>('/analytics/reports');
    } catch (_) {}
    setState(() => _isLoading = false);
  }

  void _exportDiagnosticsDialog() {
    final jsonReport = const JsonEncoder.withIndent('  ').convert(
      _apiGuard.exportDiagnostics(),
    );

    showDialog<void>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('API Observability Diagnostics (JSON)'),
        content: SizedBox(
          width: double.maxFinite,
          child: SingleChildScrollView(
            child: SelectableText(
              jsonReport,
              style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(ctx),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  void _showRecordDetailsModal(ApiRequestRecord record) {
    showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      backgroundColor: const Color(0xFF1E293B),
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      ),
      builder: (ctx) {
        return DraggableScrollableSheet(
          expand: false,
          initialChildSize: 0.7,
          maxChildSize: 0.9,
          builder: (context, scrollController) {
            return Padding(
              padding: const EdgeInsets.all(16.0),
              child: ListView(
                controller: scrollController,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        'Record ${record.id}',
                        style: const TextStyle(
                            fontSize: 18, fontWeight: FontWeight.bold),
                      ),
                      Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 8, vertical: 4),
                        decoration: BoxDecoration(
                          color: record.isSuccess ? Colors.green : Colors.red,
                          borderRadius: BorderRadius.circular(4),
                        ),
                        child: Text(
                          record.statusCode?.toString() ?? 'ERR',
                          style: const TextStyle(fontWeight: FontWeight.bold),
                        ),
                      ),
                    ],
                  ),
                  const Divider(),
                  _detailRow(
                      'Method & Path', '${record.method} ${record.path}'),
                  _detailRow('Full URL', record.url),
                  _detailRow('Duration', '${record.duration.inMilliseconds}ms'),
                  _detailRow('Retries', '${record.retryCount}'),
                  _detailRow('Slow Request', record.isSlow ? 'YES' : 'NO'),
                  _detailRow(
                      'Body Truncated', record.isBodyTruncated ? 'YES' : 'NO'),
                  if (record.errorMessage != null)
                    _detailRow('Error', record.errorMessage!, isError: true),
                  const SizedBox(height: 12),
                  const Text('Request Headers (Redacted)',
                      style: TextStyle(
                          fontWeight: FontWeight.bold,
                          color: Colors.blueAccent)),
                  _codeBlock(record.requestHeaders),
                  const SizedBox(height: 12),
                  const Text('Request Body (Redacted)',
                      style: TextStyle(
                          fontWeight: FontWeight.bold,
                          color: Colors.blueAccent)),
                  _codeBlock(record.requestBody),
                  const SizedBox(height: 12),
                  const Text('Response Payload (Redacted)',
                      style: TextStyle(
                          fontWeight: FontWeight.bold,
                          color: Colors.greenAccent)),
                  _codeBlock(record.responseBody),
                ],
              ),
            );
          },
        );
      },
    );
  }

  Widget _detailRow(String label, String value, {bool isError = false}) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 120,
            child: Text(label,
                style: const TextStyle(color: Colors.white54, fontSize: 13)),
          ),
          Expanded(
            child: Text(
              value,
              style: TextStyle(
                fontWeight: FontWeight.w600,
                color: isError ? Colors.redAccent : Colors.white,
                fontSize: 13,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _codeBlock(dynamic content) {
    String str;
    if (content == null) {
      str = 'null';
    } else if (content is String) {
      str = content;
    } else {
      try {
        str = const JsonEncoder.withIndent('  ').convert(content);
      } catch (_) {
        str = content.toString();
      }
    }

    return Container(
      padding: const EdgeInsets.all(8),
      margin: const EdgeInsets.only(top: 4),
      decoration: BoxDecoration(
        color: const Color(0xFF0F172A),
        borderRadius: BorderRadius.circular(6),
      ),
      child: SelectableText(
        str,
        style: const TextStyle(
            fontFamily: 'monospace', fontSize: 12, color: Colors.white70),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final report = _apiGuard.healthReport;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter API Observability'),
        actions: [
          IconButton(
            icon: const Icon(Icons.download),
            tooltip: 'Export JSON',
            onPressed: _exportDiagnosticsDialog,
          ),
          IconButton(
            icon: const Icon(Icons.refresh),
            tooltip: 'Reset Stats',
            onPressed: () {
              setState(() {
                _apiGuard.resetDiagnostics();
                _liveLogs.clear();
              });
            },
          ),
        ],
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(icon: Icon(Icons.dashboard), text: 'Dashboard'),
            Tab(icon: Icon(Icons.history), text: 'Request History'),
            Tab(icon: Icon(Icons.terminal), text: 'Console Logs'),
          ],
        ),
      ),
      body: SafeArea(
        child: Column(
          children: [
            if (_isLoading) const LinearProgressIndicator(),
            Expanded(
              child: TabBarView(
                controller: _tabController,
                children: [
                  _buildDashboardTab(report),
                  _buildHistoryTab(),
                  _buildConsoleTab(),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildDashboardTab(ApiHealthReport report) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _buildActionButtons(),
        const SizedBox(height: 16),
        const Text('Health Diagnostics KPIs',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
        const SizedBox(height: 10),
        GridView.count(
          crossAxisCount: 2,
          shrinkWrap: true,
          physics: const NeverScrollableScrollPhysics(),
          childAspectRatio: 2.1,
          crossAxisSpacing: 10,
          mainAxisSpacing: 10,
          children: [
            _MetricCard('Total Requests', '${report.totalRequests}',
                Icons.swap_horiz, Colors.blueAccent),
            _MetricCard(
                'Success Rate',
                '${report.successRate.toStringAsFixed(1)}%',
                Icons.check_circle,
                Colors.greenAccent),
            _MetricCard(
                'Avg Response',
                '${report.averageResponseTime.inMilliseconds}ms',
                Icons.timer,
                Colors.orangeAccent),
            _MetricCard(
                'Median Response',
                '${report.medianResponseTime.inMilliseconds}ms',
                Icons.speed,
                Colors.cyanAccent),
            _MetricCard('Slow Requests', '${report.slowRequests}', Icons.snooze,
                Colors.amber),
            _MetricCard('Total Retries', '${report.totalRetries}', Icons.replay,
                Colors.purpleAccent),
          ],
        ),
        const SizedBox(height: 20),
        const Text('Endpoint Statistics',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
        const SizedBox(height: 10),
        report.endpointStats.isEmpty
            ? const Card(
                child: Padding(
                  padding: EdgeInsets.all(16.0),
                  child: Text(
                      'No endpoint statistics available. Run simulations above.'),
                ),
              )
            : Column(
                children: report.endpointStats.values.map((ep) {
                  return Card(
                    color: const Color(0xFF1E293B),
                    margin: const EdgeInsets.only(bottom: 8),
                    child: ListTile(
                      title: Text(ep.path,
                          style: const TextStyle(fontWeight: FontWeight.bold)),
                      subtitle: Text(
                        'Requests: ${ep.totalRequests} | Success: ${ep.successRate.toStringAsFixed(0)}% | Avg: ${ep.averageDuration.inMilliseconds}ms',
                      ),
                      trailing: ep.isConsistentlySlow
                          ? Container(
                              padding: const EdgeInsets.symmetric(
                                  horizontal: 8, vertical: 4),
                              decoration: BoxDecoration(
                                color: Colors.amber.shade900,
                                borderRadius: BorderRadius.circular(4),
                              ),
                              child: const Text('SLOW',
                                  style: TextStyle(
                                      fontSize: 11,
                                      fontWeight: FontWeight.bold)),
                            )
                          : null,
                    ),
                  );
                }).toList(),
              ),
      ],
    );
  }

  Widget _buildActionButtons() {
    return Card(
      color: const Color(0xFF1E293B),
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Run Network Simulations',
                style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                ElevatedButton.icon(
                  onPressed: _isLoading ? null : _triggerSuccessRequest,
                  icon: const Icon(Icons.check, color: Colors.green),
                  label: const Text('GET /products (200)'),
                ),
                ElevatedButton.icon(
                  onPressed: _isLoading ? null : _triggerRedactedPostRequest,
                  icon: const Icon(Icons.security, color: Colors.blue),
                  label: const Text('POST /login (Redaction)'),
                ),
                ElevatedButton.icon(
                  onPressed: _isLoading ? null : _triggerUnauthorizedError,
                  icon: const Icon(Icons.no_accounts, color: Colors.purple),
                  label: const Text('GET /profile (401)'),
                ),
                ElevatedButton.icon(
                  onPressed: _isLoading ? null : _triggerServerErrorRetry,
                  icon: const Icon(Icons.replay, color: Colors.red),
                  label: const Text('GET /checkout (500 Retry)'),
                ),
                ElevatedButton.icon(
                  onPressed: _isLoading ? null : _triggerSlowRequest,
                  icon: const Icon(Icons.snooze, color: Colors.amber),
                  label: const Text('GET /reports (Slow)'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildHistoryTab() {
    final history = _apiGuard.history;

    if (history.isEmpty) {
      return const Center(
          child: Text('No request history recorded yet. Run simulations.'));
    }

    return ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: history.length,
      itemBuilder: (ctx, idx) {
        final record = history[history.length - 1 - idx]; // Newest first
        return Card(
          color: const Color(0xFF1E293B),
          margin: const EdgeInsets.only(bottom: 8),
          child: ListTile(
            onTap: () => _showRecordDetailsModal(record),
            leading: Container(
              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
              decoration: BoxDecoration(
                color: record.isSuccess
                    ? Colors.green.shade800
                    : Colors.red.shade800,
                borderRadius: BorderRadius.circular(4),
              ),
              child: Text(
                record.statusCode?.toString() ?? 'ERR',
                style:
                    const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
              ),
            ),
            title: Text('${record.method} ${record.path}',
                style: const TextStyle(fontWeight: FontWeight.bold)),
            subtitle: Text(
                'ID: ${record.id} | Duration: ${record.duration.inMilliseconds}ms | Retries: ${record.retryCount}'),
            trailing: const Icon(Icons.chevron_right),
          ),
        );
      },
    );
  }

  Widget _buildConsoleTab() {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              const Text('Console Diagnostics',
                  style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
              TextButton(
                  onPressed: () => setState(_liveLogs.clear),
                  child: const Text('Clear Log')),
            ],
          ),
          Expanded(
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: const Color(0xFF0F172A),
                borderRadius: BorderRadius.circular(10),
              ),
              child: _liveLogs.isEmpty
                  ? const Center(
                      child: Text('Console is empty.',
                          style: TextStyle(color: Colors.white54)))
                  : ListView.builder(
                      itemCount: _liveLogs.length,
                      itemBuilder: (ctx, idx) {
                        final line = _liveLogs[idx];
                        Color lineColors = Colors.white70;
                        if (line.contains('[WARN]') ||
                            line.contains('RETRY') ||
                            line.contains('SLOW')) {
                          lineColors = Colors.amberAccent;
                        } else if (line.contains('[ERROR]') ||
                            line.contains('API ERROR')) {
                          lineColors = Colors.redAccent;
                        } else if (line.contains('REDACTED')) {
                          lineColors = Colors.greenAccent;
                        }
                        return Text(
                          line,
                          style: TextStyle(
                              fontFamily: 'monospace',
                              fontSize: 12,
                              color: lineColors),
                        );
                      },
                    ),
            ),
          ),
        ],
      ),
    );
  }
}

class _MetricCard extends StatelessWidget {
  final String title;
  final String value;
  final IconData icon;
  final Color color;

  const _MetricCard(this.title, this.value, this.icon, this.color);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
      decoration: BoxDecoration(
        color: const Color(0xFF1E293B),
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: color.withAlpha(76)),
      ),
      child: Row(
        children: [
          Icon(icon, color: color, size: 24),
          const SizedBox(width: 8),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(title,
                    style:
                        const TextStyle(fontSize: 10, color: Colors.white60)),
                Text(value,
                    style: const TextStyle(
                        fontSize: 15,
                        fontWeight: FontWeight.bold,
                        color: Colors.white)),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class MockHttpClientAdapter implements HttpClientAdapter {
  ResponseBody Function(RequestOptions options)? handler;

  @override
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<Uint8List>? requestStream,
    Future<void>? cancelFuture,
  ) async {
    if (handler != null) {
      return handler!(options);
    }
    return ResponseBody.fromString(
      jsonEncode({'status': 'ok'}),
      200,
      headers: {
        Headers.contentTypeHeader: [Headers.jsonContentType],
      },
    );
  }

  @override
  void close({bool force = false}) {}
}
0
likes
130
points
79
downloads

Documentation

API reference

Publisher

verified publisherfaroukmohamed.com

Weekly Downloads

A production-grade Flutter API Observability Toolkit providing real-time request history, endpoint performance statistics, sensitive data redaction, error normalization, and health reporting for Flutter and Dart.

Repository (GitHub)
View/report issues

Topics

#dio #network #retry #logging #error-handling

License

MIT (license)

Dependencies

dio, flutter

More

Packages that depend on flutter_api_observability