Files
zapstore/lib/services/error_reporting_service.dart
T
alltheseasandClaude Opus 4.5 ca965581e2 Address code review feedback
- Remove unused originalMessage parameter from _getUserFriendlyMessage
- Add rate limiting to user-initiated error reports
- Fix empty description showing "Technical details" toggle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 14:46:28 -06:00

180 lines
5.7 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart';
/// Service for reporting errors via NIP-44 encrypted DMs to Zapstore team.
///
/// Generates an ephemeral signer per report so errors can be sent even when
/// the user is not signed in.
class ErrorReportingService {
ErrorReportingService(this.ref);
final Ref ref;
/// Track reported errors to avoid spam (error hash -> timestamp)
final Map<int, DateTime> _reportedErrors = {};
/// Rate limit: one report per error type per session
static const _rateLimitDuration = Duration(minutes: 30);
/// Maximum reports per session to prevent abuse
static const _maxReportsPerSession = 10;
int _sessionReportCount = 0;
/// Report an error to the Zapstore team via encrypted DM.
///
/// Rate-limited to prevent spam. Only sends one report per unique
/// error type per session.
Future<void> reportError(Object exception, StackTrace? stackTrace) async {
// Don't report in debug mode
if (kDebugMode) {
return;
}
// Check session limit
if (_sessionReportCount >= _maxReportsPerSession) {
return;
}
// Rate limit by error type
final errorHash = exception.toString().hashCode;
final lastReport = _reportedErrors[errorHash];
if (lastReport != null &&
DateTime.now().difference(lastReport) < _rateLimitDuration) {
return;
}
try {
// Create ephemeral signer for error reporting
final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref);
await signer.signIn(setAsActive: false, registerSigner: false);
// Format error report
final report = _formatErrorReport(exception, stackTrace);
// Create encrypted DM to Zapstore pubkey (uses NIP-44 by default)
final dm = PartialDirectMessage(
content: report,
receiver: kZapstorePubkey,
);
// Sign and publish
final signedDm = await dm.signWith(signer);
await ref.read(storageNotifierProvider.notifier).publish({
signedDm,
}, source: const RemoteSource(relays: 'social'));
// Update rate limiting
_reportedErrors[errorHash] = DateTime.now();
_sessionReportCount++;
} catch (e) {
// Silently fail - we don't want error reporting to cause more errors
}
}
/// User-initiated error report (e.g., from "Report issue" button).
///
/// Returns true if the report was sent successfully.
/// Rate-limited to prevent accidental spam from repeated taps.
Future<bool> reportUserError({
required String title,
String? technicalDetails,
}) async {
// Check session limit
if (_sessionReportCount >= _maxReportsPerSession) {
return false;
}
// Rate limit by error content
final errorHash = '$title\n${technicalDetails ?? ''}'.hashCode;
final lastReport = _reportedErrors[errorHash];
if (lastReport != null &&
DateTime.now().difference(lastReport) < _rateLimitDuration) {
return false;
}
try {
// Create ephemeral signer for error reporting
final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref);
await signer.signIn(setAsActive: false, registerSigner: false);
// Format error report
final report = _formatUserErrorReport(title, technicalDetails);
// Create encrypted DM to Zapstore pubkey (uses NIP-44 by default)
final dm = PartialDirectMessage(
content: report,
receiver: kZapstorePubkey,
);
// Sign and publish
final signedDm = await dm.signWith(signer);
await ref.read(storageNotifierProvider.notifier).publish({
signedDm,
}, source: const RemoteSource(relays: 'social'));
// Update rate limiting
_reportedErrors[errorHash] = DateTime.now();
_sessionReportCount++;
return true;
} catch (e) {
debugPrint('[ErrorReporting] Failed to send user report: $e');
return false;
}
}
String _formatUserErrorReport(String title, String? technicalDetails) {
final buffer = StringBuffer();
buffer.writeln('=== ZAPSTORE USER ERROR REPORT ===');
buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}');
buffer.writeln('Platform: ${Platform.operatingSystem}');
buffer.writeln('OS Version: ${Platform.operatingSystemVersion}');
buffer.writeln('');
buffer.writeln('Error: $title');
if (technicalDetails != null && technicalDetails.isNotEmpty) {
buffer.writeln('');
buffer.writeln('Technical Details:');
buffer.writeln(technicalDetails);
}
return buffer.toString();
}
String _formatErrorReport(Object exception, StackTrace? stackTrace) {
final buffer = StringBuffer();
buffer.writeln('=== ZAPSTORE ERROR REPORT ===');
buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}');
buffer.writeln('Platform: ${Platform.operatingSystem}');
buffer.writeln('OS Version: ${Platform.operatingSystemVersion}');
buffer.writeln('');
buffer.writeln('Exception:');
buffer.writeln(exception.toString());
buffer.writeln('');
if (stackTrace != null) {
buffer.writeln('Stack Trace:');
// Limit stack trace length to avoid huge messages
final stackLines = stackTrace.toString().split('\n');
final limitedStack = stackLines.take(30).join('\n');
buffer.writeln(limitedStack);
if (stackLines.length > 30) {
buffer.writeln('... (${stackLines.length - 30} more lines)');
}
}
return buffer.toString();
}
}
/// Provider for the error reporting service
final errorReportingServiceProvider = Provider<ErrorReportingService>(
ErrorReportingService.new,
);