flutter_api_observability 1.1.0
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 [...]
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}) {}
}