diff --git a/2.Flutter 埋点 SDK 设计方案(独立 Dart 实现).md b/2.Flutter 埋点 SDK 设计方案(独立 Dart 实现).md index cb0179b..484d8a0 100644 --- a/2.Flutter 埋点 SDK 设计方案(独立 Dart 实现).md +++ b/2.Flutter 埋点 SDK 设计方案(独立 Dart 实现).md @@ -47,7 +47,9 @@ "screenResolution": "string" }, "eventParams": { - // 事件上下文 + "Page": "string", + "Url": "string", + "ButtonId": "string" }, "customTags": { // 业务维度 @@ -90,7 +92,7 @@ class Analytics { ```dart class AnalyticsConfig { final String systemCode; - final String endpointBaseUrl; // https://host/api/ExternalEventlogs + final String endpointBaseUrl; // https://host(仅基础 host) final int clientType; // 1=Android, 2=iOS, 3=Flutter final bool enableDebug; diff --git a/4.Example App 集成实施方案.md b/4.Example App 集成实施方案.md index 2ad98a8..7561f3d 100644 --- a/4.Example App 集成实施方案.md +++ b/4.Example App 集成实施方案.md @@ -12,7 +12,7 @@ | 项目 | 值 | |------|---| -| **API 基础地址** | `http://192.168.2.7:18828/api/ExternalEventlogs` | +| **API 基础地址** | `http://192.168.2.7:18828` | | **系统标识** | `SDK-TEST-FLUTTER` | | **系统名称** | Flutter SDK测试 | | **认证方式** | 无需认证 | @@ -47,9 +47,9 @@ await Analytics.init( const AnalyticsConfig( - systemCode: 'DEMO_APP', -- endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', +- endpointBaseUrl: 'https://example.com', + systemCode: 'SDK-TEST-FLUTTER', -+ endpointBaseUrl: 'http://192.168.2.7:18828/api/ExternalEventlogs', ++ endpointBaseUrl: 'http://192.168.2.7:18828', clientType: 3, enableDebug: true, batchSize: 5, @@ -154,7 +154,11 @@ Content-Type: application/json "model": "MacBook Pro", "screenResolution": "1920x1080" }, - "eventParams": {"page": "demo"}, + "eventParams": { + "Page": "demo", + "Url": "https://example.com/demo", + "ButtonId": "demo_btn_01" + }, "customTags": {"tenantId": "t1", "feature": "demo"} } ] diff --git a/README.md b/README.md index 1e99597..45d8b00 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Future bootstrapAnalytics() async { await Analytics.init( const AnalyticsConfig( systemCode: 'OA_APP', - endpointBaseUrl: 'https://your-host/api/ExternalEventlogs', + endpointBaseUrl: 'https://your-host', clientType: 3, enableDebug: true, ), @@ -47,7 +47,11 @@ Future bootstrapAnalytics() async { ```dart await Analytics.track( 'PAGE_VIEW', - eventParams: const {'page': 'home'}, + eventParams: const { + 'Page': 'home', + 'Url': 'https://example.com/home', + 'ButtonId': 'page_view', + }, customTags: const {'tenantId': 't1'}, ); ``` @@ -61,6 +65,7 @@ await Analytics.flush(force: true); ## 关键配置项(AnalyticsConfig) 除了文档中的 Phase 1 配置项,还新增了以下能力配置: +- `endpointBaseUrl`:仅填写基础 host(如 `https://your-host`),SDK 会自动拼接 `/api/ExternalEventlogs/*` 路径 - `useIsolateStorage`: 是否使用 Isolate 进行存储操作(默认 `true`,强烈建议在 Flutter 环境开启) - `allowInsecureHttp`:是否允许使用 HTTP(默认 `false`,仅用于开发/测试环境) - `enableMetrics`:是否启用 SDK 自监控指标(默认 `true`) diff --git a/docs/example_app_test_plan.md b/docs/example_app_test_plan.md new file mode 100644 index 0000000..9e7197c --- /dev/null +++ b/docs/example_app_test_plan.md @@ -0,0 +1,154 @@ +# Example App Comprehensive Test Plan +# 示例应用综合测试规划 + +## Goal / 目标 +Ensure the SDK works reliably in production and any SDK errors do not crash or impact the host app. +确保 SDK 在生产环境稳定运行,任何 SDK 错误都不会导致宿主应用崩溃或影响正常使用。 + +--- + +## 项目现状与差距分析(基于当前实现) +1. **Example App 已具备基础入口**:`Track/Flush/Refresh Config`,支持缓存条数与最近事件列表。 +2. **配置拉取参数为 `systemCode`**,需后端接口一致;联调地址为 HTTP 时必须开启 `allowInsecureHttp`。 +3. **生命周期自动 flush 已实现**,但 Example App 需要绑定 `Analytics.bindLifecycleObserver()`。 +4. **Isolate 存储默认启用**,并支持失败降级到 sqflite;Example App 需补充可验证路径(或增加调试开关)。 +5. **Validator 行为依赖后端配置**(必填 tag/事件定义需在后台配置)。 + +## Test Categories / 测试分类 + +### 1. Stress Testing / 压力测试 +| Feature / 功能 | Description / 描述 | +|---|---| +| **Batch Track (1000 events)** | Rapidly track 1000 events to test storage throughput. / 快速记录 1000 条事件,测试存储吞吐量。 | +| **Concurrent Track** | Fire multiple `Future`s concurrently (same isolate). / 同一 isolate 内并发触发多个 Future。 | +| **Continuous Flush** | Repeatedly flush while events are still being added. / 在持续添加事件的同时反复 flush。 | + +### 2. Error Handling / 错误处理 +| Feature / 功能 | Description / 描述 | +|---|---| +| **Network Failure Simulation** | Disconnect network and track events, verify queue persists and retries. / 断网状态下追踪事件,验证队列是否持久化并可重试。 | +| **Server Error (5xx) Simulation** | Point to an endpoint that returns 500 errors. / 指向返回 500 的端点。 | +| **Invalid Payload Test** | Track events missing required tags (requires backend config). / 追踪缺少必填标签的事件(依赖后台配置)。 | + +### 3. Edge Cases / 边界情况 +| Feature / 功能 | Description / 描述 | +|---|---| +| **Empty Event Params** | Track event with no `eventParams`. / 追踪不带 `eventParams` 的事件。 | +| **Very Large Payload** | Track event with a very large JSON payload (e.g., 100KB). / 追踪一个超大 JSON 负载的事件 (例如 100KB)。 | +| **Special Characters** | Track event with special characters in `eventType` or params. / 在 `eventType` 或参数中使用特殊字符。 | +| **Rapid Init/Dispose** | Initialize and dispose SDK multiple times quickly. / 快速多次初始化和释放 SDK。 | +| **Background/Foreground Transitions** | Track events while app is backgrounded. / 在应用后台时追踪事件。 | +| **Event Expiration** | Insert old events and verify cleanup. / 插入过期事件并验证清理。 | + +### 4. SDK Robustness (Isolation) / SDK 健壮性(隔离性) +| Feature / 功能 | Description / 描述 | +|---|---| +| **Exception Swallowing** | Verify SDK exceptions are caught and logged, not propagated to app. / 验证 SDK 异常被捕获并记录,而不是传递给应用。 | +| **Timeout Handling** | Verify network timeouts don't freeze the app. / 验证网络超时不会冻结应用。 | +| **Fallback Storage** | Force Isolate init failure and verify fallback. / 人为触发 Isolate 初始化失败并验证回退。 | + +--- + +## Proposed UI Structure / 建议的 UI 结构 + +``` +DemoApp +├── Basic Actions / 基本操作 +│ ├── Track Demo Event +│ ├── Flush Now +│ └── Refresh Config +│ +├── Stress Tests / 压力测试 +│ ├── Track 1000 Events (Concurrent) +│ ├── Track 5000 Events (Sequential) +│ └── Continuous Track + Flush +│ +├── Error Simulation / 错误模拟 +│ ├── Track Invalid Event (missing fields) +│ ├── Simulate Network Timeout +│ └── Simulate Server 5xx Error +│ +├── Edge Cases / 边界情况 +│ ├── Track Empty Params Event +│ ├── Track Large Payload Event +│ └── Rapid Init/Dispose Cycle +│ +└── Status & Logs / 状态与日志 + ├── Cache Count + ├── Recent Events List + └── Real-time Log Output +``` + +--- + +## 具体测试用例(结合当前 SDK) + +### A. 基础联通(必做) +1. **初始化与配置** + - 配置 `allowInsecureHttp: true`(仅联调)。 + - `systemCode` 与后端配置一致。 + - 期望:初始化成功,不抛异常。 +2. **Track + Flush** + - 连续点击 Track 5 次,触发 flush。 + - 期望:缓存减少,后端收到事件。 +3. **Refresh Config** + - 点击刷新配置。 + - 期望:ConfigManager 拉取成功或日志提示跳过(未过期)。 + +### B. 压力与稳定性 +1. **1000 条批量写入** + - 一键触发批量 track。 + - 期望:无崩溃,无明显卡顿;缓存数正确。 +2. **并发写入** + - 同时启动 10 个 Future,每个 100 条。 + - 期望:无异常;最终缓存数正确。 +3. **持续 flush** + - 循环 `track + flush` 30 秒。 + - 期望:无死锁;重试与退避日志正常。 + +### C. 错误处理 +1. **断网** + - 断网后 track,恢复网络再 flush。 + - 期望:队列持久化,恢复后上报成功。 +2. **后端 5xx** + - 指向返回 500 的端点。 + - 期望:retryCount 增加,事件不丢失。 +3. **必填标签缺失** + - 后端配置必填 tag,track 不带该字段。 + - 期望:Debug 模式 log warning;Release 模式打 `_sdk_*` 标签。 + +### D. 边界与健壮性 +1. **空参数** + - `eventParams=null` 或空对象。 + - 期望:事件可正常入库。 +2. **超大 payload** + - `eventParams` 约 100KB。 + - 期望:入库正常,flush 不崩。 +3. **特殊字符** + - `eventType` 含空格/emoji/符号。 + - 期望:入库正常,后端能接收。 +4. **生命周期** + - 进入后台触发自动 flush。 + - 期望:日志中出现 flush,缓存减少。 +5. **Isolate 回退** + - 临时在 debug 分支让 Isolate init 抛错,观察回退日志。 + - 期望:自动降级至 sqflite,功能不受影响。 + +--- + +## Implementation Priority / 实现优先级 + +1. **[High/高]** Stress Test (1000 events) - Already in progress. +2. **[High/高]** Error Handling - Network failure and retry behavior. +3. **[Medium/中]** Edge Cases - Large payload, special characters. +4. **[Medium/中]** SDK Robustness - Exception isolation verification. +5. **[Low/低]** Advanced UI - Real-time log display. + +--- + +## Next Steps / 下一步 +1. Finish implementing `_runStressTest` method. +2. Add error simulation buttons. +3. Add edge case test buttons. +4. Add real-time log display widget. +5. Add a toggle for `allowInsecureHttp` and `useIsolateStorage` in demo. diff --git a/example/lib/main.dart b/example/lib/main.dart index 2ec16c7..8b548e5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,21 +3,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:yx_tracking_flutter/yx_tracking_flutter.dart'; +final AnalyticsConfig _defaultConfig = AnalyticsConfig( + systemCode: 'SDK-TEST-FLUTTER', + endpointBaseUrl: 'http://192.168.2.7:18828', + // clientType: 3, // Auto-detected + enableDebug: true, + batchSize: 30, + flushInterval: 30, + allowInsecureHttp: true, +); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await Analytics.init( - const AnalyticsConfig( - systemCode: 'SDK-TEST-FLUTTER', - endpointBaseUrl: 'http://192.168.2.7:18828/api/', - clientType: 3, - enableDebug: true, - batchSize: 5, - flushInterval: 30, - allowInsecureHttp: true, - ), - ); - + await Analytics.init(_defaultConfig); Analytics.bindLifecycleObserver(); runApp(const DemoApp()); @@ -28,7 +27,10 @@ class DemoApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp(home: DemoPage()); + return MaterialApp( + home: const DemoPage(), + theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true), + ); } } @@ -42,13 +44,20 @@ class DemoPage extends StatefulWidget { class _DemoPageState extends State { int _cacheCount = 0; List _recent = const []; - bool _flushing = false; - bool _refreshingConfig = false; + final List _logs = []; + final Set _running = {}; + bool _verboseStressLogs = false; + bool _sdkDebugEnabled = true; + final TextEditingController _mockBaseUrlController = TextEditingController(); + final ScrollController _logScrollController = ScrollController(); Timer? _pollTimer; + late AnalyticsConfig _activeConfig; @override void initState() { super.initState(); + _activeConfig = _defaultConfig; + _sdkDebugEnabled = _activeConfig.enableDebug; _refreshCount(); _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) { _refreshCount(); @@ -58,9 +67,72 @@ class _DemoPageState extends State { @override void dispose() { _pollTimer?.cancel(); + _mockBaseUrlController.dispose(); + _logScrollController.dispose(); super.dispose(); } + bool _isRunning(String key) => _running.contains(key); + + Future _runAction( + String key, + String label, + Future Function() action, { + bool suppressSdkLogs = false, + }) async { + if (_isRunning(key)) { + return; + } + setState(() { + _running.add(key); + }); + final start = DateTime.now(); + _addLog('▶ $label'); + final previousDebug = _sdkDebugEnabled; + if (suppressSdkLogs) { + _setSdkDebug(false, reason: '压力测试静默'); + } + try { + await action(); + final elapsed = DateTime.now().difference(start).inMilliseconds; + _addLog('✅ $label (${elapsed}ms)'); + } on Object catch (e) { + _addLog('❌ $label: $e'); + } finally { + if (suppressSdkLogs) { + _setSdkDebug(previousDebug, reason: '恢复日志'); + } + if (mounted) { + setState(() { + _running.remove(key); + }); + } + await _refreshCount(); + } + } + + void _addLog(String message) { + if (!mounted) { + return; + } + final now = DateTime.now(); + final ts = + '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}'; + setState(() { + _logs.add('[$ts] $message'); + if (_logs.length > 200) { + _logs.removeRange(0, _logs.length - 200); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScrollController.hasClients) { + _logScrollController.jumpTo( + _logScrollController.position.maxScrollExtent, + ); + } + }); + } + Future _refreshCount() async { final results = await Future.wait(>[ Analytics.cachedEventCount(), @@ -77,121 +149,574 @@ class _DemoPageState extends State { }); } + Map _requiredParams({ + required String page, + required String url, + required String buttonId, + Map? extra, + }) { + final params = { + 'Page': page, + 'Url': url, + 'ButtonId': buttonId, + }; + if (extra != null && extra.isNotEmpty) { + params.addAll(extra); + } + return params; + } + + AnalyticsConfig _buildConfig({ + String? endpointBaseUrl, + Duration? maxEventAge, + bool? allowInsecureHttp, + bool? useIsolateStorage, + Duration? connectTimeout, + Duration? readTimeout, + }) { + final base = _activeConfig; + return AnalyticsConfig( + systemCode: base.systemCode, + endpointBaseUrl: endpointBaseUrl ?? base.endpointBaseUrl, + clientType: base.clientType, + enableDebug: base.enableDebug, + batchSize: base.batchSize, + flushInterval: base.flushInterval, + maxCacheSize: base.maxCacheSize, + maxRetryCount: base.maxRetryCount, + connectTimeout: connectTimeout ?? base.connectTimeout, + readTimeout: readTimeout ?? base.readTimeout, + maxEventAge: maxEventAge ?? base.maxEventAge, + useIsolateStorage: useIsolateStorage ?? base.useIsolateStorage, + allowInsecureHttp: allowInsecureHttp ?? base.allowInsecureHttp, + enableMetrics: base.enableMetrics, + metricsReportInterval: base.metricsReportInterval, + blockOnValidationError: base.blockOnValidationError, + ); + } + + Future _reinitialize(AnalyticsConfig config, {String? reason}) async { + await Analytics.init(config); + if (!mounted) { + return; + } + setState(() { + _activeConfig = config; + _sdkDebugEnabled = config.enableDebug; + }); + if (reason != null && reason.isNotEmpty) { + _addLog('SDK 重新初始化: $reason'); + } + } + + void _setSdkDebug(bool enabled, {required String reason}) { + if (_sdkDebugEnabled == enabled) { + return; + } + Analytics.setDebug(enabled: enabled); + setState(() { + _sdkDebugEnabled = enabled; + }); + _addLog('SDK 日志${enabled ? '开启' : '关闭'}($reason)'); + } + Future _trackDemoEvent() async { await Analytics.track( 'DEMO_BUTTON_CLICK', - eventParams: const { - 'page': 'demo', - 'Url': 'https://example.com/demo', - 'ButtonId': 'demo_btn_01', - }, + eventParams: _requiredParams( + page: 'demo', + url: 'https://example.com/demo', + buttonId: 'demo_btn_01', + ), customTags: const {'tenantId': 't1', 'feature': 'demo'}, ); - await _refreshCount(); } Future _flushNow() async { - if (_flushing) { - return; - } - setState(() { - _flushing = true; - }); - try { - await Analytics.flush(force: true); - } finally { - if (mounted) { - setState(() { - _flushing = false; - }); - } - await _refreshCount(); - } + await Analytics.flush(force: true); } Future _refreshConfig() async { - if (_refreshingConfig) { - return; + await Analytics.refreshConfig(force: true); + } + + Future _runStressSequential() async { + for (var i = 0; i < 1000; i += 1) { + await Analytics.track( + 'STRESS_SEQ', + eventParams: _requiredParams( + page: 'stress_seq', + url: 'https://example.com/stress', + buttonId: 'stress_seq', + extra: {'index': i}, + ), + customTags: const {'feature': 'stress_seq'}, + ); } - setState(() { - _refreshingConfig = true; - }); - try { - await Analytics.refreshConfig(force: true); - } finally { - if (mounted) { - setState(() { - _refreshingConfig = false; - }); + } + + Future _runStressConcurrent() async { + Future trackBatch(int batch, int count) async { + for (var i = 0; i < count; i += 1) { + await Analytics.track( + 'STRESS_CONCURRENT', + eventParams: _requiredParams( + page: 'stress_concurrent', + url: 'https://example.com/stress', + buttonId: 'stress_concurrent', + extra: {'batch': batch, 'index': i}, + ), + customTags: const {'feature': 'stress_concurrent'}, + ); } } + + final tasks = List>.generate( + 10, + (index) => trackBatch(index, 100), + ); + await Future.wait(tasks); + } + + Future _runContinuousTrackFlush() async { + final endAt = DateTime.now().add(const Duration(seconds: 30)); + final trackTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { + unawaited( + Analytics.track( + 'CONTINUOUS_EVENT', + eventParams: _requiredParams( + page: 'continuous', + url: 'https://example.com/continuous', + buttonId: 'continuous', + extra: { + 'ts': DateTime.now().millisecondsSinceEpoch, + }, + ), + customTags: const {'feature': 'continuous'}, + ), + ); + }); + final flushTimer = Timer.periodic(const Duration(seconds: 1), (_) { + unawaited(Analytics.flush(force: true)); + }); + + while (DateTime.now().isBefore(endAt)) { + await Future.delayed(const Duration(milliseconds: 500)); + } + + trackTimer.cancel(); + flushTimer.cancel(); + } + + Future _trackInvalidEvent() async { + await Analytics.track( + 'DEMO_BUTTON_CLICK', + eventParams: _requiredParams( + page: 'demo_invalid', + url: 'https://example.com/invalid', + buttonId: 'demo_invalid', + ), + customTags: const {'tenantId': 't1'}, + ); + } + + Future _simulateTimeout() async { + final original = _activeConfig; + final tempConfig = _buildConfig( + endpointBaseUrl: 'http://10.255.255.1:18828', + allowInsecureHttp: true, + connectTimeout: const Duration(seconds: 1), + readTimeout: const Duration(seconds: 1), + ); + await _reinitialize(tempConfig, reason: '网络超时模拟'); + await Analytics.track( + 'EVENT_TIMEOUT_TEST', + eventParams: _requiredParams( + page: 'timeout', + url: 'https://example.com/timeout', + buttonId: 'timeout', + extra: const {'case': 'timeout'}, + ), + ); + await Analytics.flush(force: true); + await _reinitialize(original, reason: '恢复正常配置'); + } + + Future _simulateServerError() async { + final mock = _mockBaseUrlController.text.trim(); + if (mock.isEmpty) { + _addLog('⚠️ 请先填写 5xx/Mock BaseUrl'); + return; + } + final original = _activeConfig; + final tempConfig = _buildConfig( + endpointBaseUrl: mock, + allowInsecureHttp: mock.startsWith('http://'), + ); + await _reinitialize(tempConfig, reason: '5xx 模拟'); + await Analytics.track( + 'EVENT_5XX_TEST', + eventParams: _requiredParams( + page: 'server_5xx', + url: 'https://example.com/5xx', + buttonId: 'server_5xx', + extra: const {'case': 'server_5xx'}, + ), + ); + await Analytics.flush(force: true); + await _reinitialize(original, reason: '恢复正常配置'); + } + + Future _trackEmptyParams() async { + await Analytics.track('EMPTY_PARAMS_EVENT'); + } + + Future _trackLargePayload() async { + final payload = List.filled(100 * 1024, 'a').join(); + await Analytics.track( + 'LARGE_PAYLOAD_EVENT', + eventParams: _requiredParams( + page: 'large_payload', + url: 'https://example.com/large', + buttonId: 'large_payload', + extra: {'payload': payload, 'size': payload.length}, + ), + ); + } + + Future _trackSpecialChars() async { + await Analytics.track( + 'SPECIAL_空格_😀_!@#', + eventParams: _requiredParams( + page: 'special_chars', + url: 'https://example.com/special', + buttonId: 'special_chars', + extra: const {'feature': 'special_chars'}, + ), + ); + } + + Future _rapidInitDispose() async { + for (var i = 0; i < 5; i += 1) { + await Analytics.dispose(); + await Analytics.init(_activeConfig); + } + } + + Future _testExpiration() async { + final original = _activeConfig; + final tempConfig = _buildConfig(maxEventAge: const Duration(seconds: 1)); + await _reinitialize(tempConfig, reason: '过期清理测试'); + await Analytics.track( + 'EXPIRATION_EVENT', + eventParams: _requiredParams( + page: 'expiration', + url: 'https://example.com/expiration', + buttonId: 'expiration', + extra: const {'case': 'expiration'}, + ), + ); + await Future.delayed(const Duration(seconds: 2)); + await Analytics.flush(force: true); + await _reinitialize(original, reason: '恢复正常配置'); + } + + Widget _buildSection(String title, List children) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ...children, + ], + ), + ), + ); + } + + Widget _buildConfigSummary() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Endpoint: ${_activeConfig.endpointBaseUrl}'), + Text('systemCode: ${_activeConfig.systemCode}'), + Text('allowInsecureHttp: ${_activeConfig.allowInsecureHttp}'), + Text('useIsolateStorage: ${_activeConfig.useIsolateStorage}'), + Text('maxEventAge: ${_activeConfig.maxEventAge.inSeconds}s'), + ], + ); + } + + Widget _buildRecentList() { + if (_recent.isEmpty) { + return const Text('暂无事件'); + } + return ListView.separated( + itemCount: _recent.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = _recent[index]; + return ListTile( + dense: true, + title: Text(item.eventType), + subtitle: Text( + '${item.createTime.toIso8601String()} · retry=${item.retryCount}', + ), + ); + }, + ); + } + + Widget _buildLogList() { + if (_logs.isEmpty) { + return const Text('暂无日志'); + } + return ListView.builder( + controller: _logScrollController, + itemCount: _logs.length, + itemBuilder: (context, index) => Text(_logs[index]), + ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('YX Tracking Demo')), - body: Padding( + body: ListView( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '本地缓存事件数:$_cacheCount', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), + children: [ + Text( + '本地缓存事件数:$_cacheCount', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + _buildSection('配置摘要', [_buildConfigSummary()]), + _buildSection('基础操作', [ Wrap( spacing: 12, runSpacing: 12, children: [ ElevatedButton( - onPressed: _trackDemoEvent, + onPressed: _isRunning('track_demo') + ? null + : () => _runAction( + 'track_demo', + 'Track Demo Event', + _trackDemoEvent, + ), child: const Text('Track Demo Event'), ), ElevatedButton( - onPressed: _flushing ? null : _flushNow, - child: Text(_flushing ? 'Flushing...' : 'Flush Now'), + onPressed: _isRunning('flush') + ? null + : () => _runAction('flush', 'Flush Now', _flushNow), + child: Text( + _isRunning('flush') ? 'Flushing...' : 'Flush Now', + ), ), OutlinedButton( - onPressed: _refreshingConfig ? null : _refreshConfig, + onPressed: _isRunning('refresh_config') + ? null + : () => _runAction( + 'refresh_config', + 'Refresh Config', + _refreshConfig, + ), child: Text( - _refreshingConfig ? 'Refreshing...' : 'Refresh Config', + _isRunning('refresh_config') + ? 'Refreshing...' + : 'Refresh Config', ), ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 8), const Text( - '说明:已对接 SDK-TEST-FLUTTER 系统,' - '点击 Track 按钮记录事件,点击 Flush 上报。', + '说明:已对接 SDK-TEST-FLUTTER 系统;若为 HTTP 联调,请保持 allowInsecureHttp=true。', ), - const SizedBox(height: 12), - Text( - '最近事件(最多 20 条)', - style: Theme.of(context).textTheme.titleSmall, + ]), + _buildSection('压力测试', [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('压力测试详细日志'), + subtitle: const Text('关闭后仅输出结果与异常'), + value: _verboseStressLogs, + onChanged: (value) { + setState(() { + _verboseStressLogs = value; + }); + }, + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + onPressed: _isRunning('stress_seq') + ? null + : () => _runAction( + 'stress_seq', + 'Track 1000 (Sequential)', + _runStressSequential, + suppressSdkLogs: !_verboseStressLogs, + ), + child: Text( + _isRunning('stress_seq') + ? 'Testing...' + : 'Track 1000 (Sequential)', + ), + ), + OutlinedButton( + onPressed: _isRunning('stress_con') + ? null + : () => _runAction( + 'stress_con', + 'Track 1000 (Concurrent)', + _runStressConcurrent, + suppressSdkLogs: !_verboseStressLogs, + ), + child: Text( + _isRunning('stress_con') + ? 'Testing...' + : 'Track 1000 (Concurrent)', + ), + ), + OutlinedButton( + onPressed: _isRunning('continuous') + ? null + : () => _runAction( + 'continuous', + 'Continuous Track + Flush', + _runContinuousTrackFlush, + suppressSdkLogs: !_verboseStressLogs, + ), + child: Text( + _isRunning('continuous') + ? 'Running...' + : 'Continuous Track + Flush (30s)', + ), + ), + ], + ), + ]), + _buildSection('错误模拟', [ + TextField( + controller: _mockBaseUrlController, + decoration: const InputDecoration( + labelText: '5xx/Mock BaseUrl(可选)', + hintText: 'http://localhost:8080', + ), ), const SizedBox(height: 8), - Expanded( - child: _recent.isEmpty - ? const Text('暂无事件') - : ListView.separated( - itemCount: _recent.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = _recent[index]; - return ListTile( - dense: true, - title: Text(item.eventType), - subtitle: Text( - '${item.createTime.toIso8601String()} · retry=${item.retryCount}', - ), - ); - }, - ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + onPressed: _isRunning('invalid_event') + ? null + : () => _runAction( + 'invalid_event', + 'Invalid Event (Missing Tag)', + _trackInvalidEvent, + ), + child: const Text('Invalid Event (Missing Tag)'), + ), + OutlinedButton( + onPressed: _isRunning('timeout') + ? null + : () => _runAction( + 'timeout', + 'Simulate Network Timeout', + _simulateTimeout, + ), + child: const Text('Simulate Network Timeout'), + ), + OutlinedButton( + onPressed: _isRunning('server_5xx') + ? null + : () => _runAction( + 'server_5xx', + 'Simulate Server 5xx', + _simulateServerError, + ), + child: const Text('Simulate Server 5xx'), + ), + ], ), - ], - ), + ]), + _buildSection('边界用例', [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + onPressed: _isRunning('empty_params') + ? null + : () => _runAction( + 'empty_params', + 'Empty Params', + _trackEmptyParams, + ), + child: const Text('Empty Params'), + ), + OutlinedButton( + onPressed: _isRunning('large_payload') + ? null + : () => _runAction( + 'large_payload', + 'Large Payload (100KB)', + _trackLargePayload, + ), + child: const Text('Large Payload (100KB)'), + ), + OutlinedButton( + onPressed: _isRunning('special_chars') + ? null + : () => _runAction( + 'special_chars', + 'Special Characters', + _trackSpecialChars, + ), + child: const Text('Special Characters'), + ), + OutlinedButton( + onPressed: _isRunning('rapid_init') + ? null + : () => _runAction( + 'rapid_init', + 'Rapid Init/Dispose (x5)', + _rapidInitDispose, + ), + child: const Text('Rapid Init/Dispose (x5)'), + ), + OutlinedButton( + onPressed: _isRunning('expiration') + ? null + : () => _runAction( + 'expiration', + 'Test Expiration (1s)', + _testExpiration, + ), + child: const Text('Test Expiration (1s)'), + ), + ], + ), + ]), + _buildSection('状态与日志', [ + const Text('最近事件(最多 20 条)'), + const SizedBox(height: 8), + SizedBox(height: 220, child: _buildRecentList()), + const SizedBox(height: 12), + const Text('日志输出'), + const SizedBox(height: 8), + SizedBox(height: 160, child: _buildLogList()), + ]), + ], ), ); } diff --git a/lib/src/config/analytics_config.dart b/lib/src/config/analytics_config.dart index 77ffb14..78a155a 100644 --- a/lib/src/config/analytics_config.dart +++ b/lib/src/config/analytics_config.dart @@ -1,12 +1,13 @@ -import 'dart:core'; +import 'package:flutter/foundation.dart'; +import 'package:yx_tracking_flutter/src/model/client_type.dart'; /// SDK 初始化配置。 class AnalyticsConfig { /// 创建 SDK 配置实例。 - const AnalyticsConfig({ + AnalyticsConfig({ required this.systemCode, required this.endpointBaseUrl, - required this.clientType, + int? clientType, this.enableDebug = false, this.batchSize = 20, this.flushInterval = 15, @@ -20,7 +21,25 @@ class AnalyticsConfig { this.enableMetrics = true, this.metricsReportInterval = const Duration(minutes: 10), this.blockOnValidationError = false, - }); + }) : clientType = clientType ?? _detectClientType(); + + /// 自动探测客户端类型。 + static int _detectClientType() { + if (kIsWeb) { + return ClientType.h5.value; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return ClientType.android.value; + case TargetPlatform.iOS: + return ClientType.ios.value; + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return ClientType.pc.value; + } + } /// 系统编码(system_code)。 final String systemCode; @@ -86,6 +105,13 @@ class AnalyticsConfig { 'endpointBaseUrl 不是合法的 URL', ); } + if (uri.path.isNotEmpty && uri.path != '/') { + throw ArgumentError.value( + endpointBaseUrl, + 'endpointBaseUrl', + 'endpointBaseUrl 仅允许配置基础 host(不包含路径)', + ); + } final scheme = uri.scheme.toLowerCase(); if (scheme != 'https') { if (scheme == 'http' && allowInsecureHttp) { @@ -166,23 +192,34 @@ class AnalyticsConfig { /// `/AddEventListLog` 的完整地址。 Uri get addEventListLogUri => - _appendPath('ExternalEventlogs/AddEventListLog'); + _appendPath('api/ExternalEventlogs/AddEventListLog'); /// `/AddEventLog` 的完整地址(可选降级)。 - Uri get addEventLogUri => _appendPath('ExternalEventlogs/AddEventLog'); + Uri get addEventLogUri => _appendPath('api/ExternalEventlogs/AddEventLog'); /// `/GetSystemAllDimInfo` 的完整地址(Phase 2+)。 Uri get getSystemAllDimInfoUri => - _appendPath('ExternalEventlogs/GetSystemAllDimInfo'); + _appendPath('api/ExternalEventlogs/GetSystemAllDimInfo'); Uri _appendPath(String leaf) { final base = Uri.parse(endpointBaseUrl); final normalizedBasePath = base.path.endsWith('/') ? base.path.substring(0, base.path.length - 1) : base.path; - final nextPath = - normalizedBasePath.isEmpty ? '/$leaf' : '$normalizedBasePath/$leaf'; + final normalizedLeaf = _normalizeLeaf(normalizedBasePath, leaf); + final nextPath = normalizedBasePath.isEmpty + ? '/$normalizedLeaf' + : '$normalizedBasePath/$normalizedLeaf'; return base.replace(path: nextPath); } + + String _normalizeLeaf(String basePath, String leaf) { + const externalSegment = 'ExternalEventlogs/'; + if (basePath.endsWith('/ExternalEventlogs') && + leaf.startsWith(externalSegment)) { + return leaf.substring(externalSegment.length); + } + return leaf; + } } diff --git a/lib/src/config/config_manager.dart b/lib/src/config/config_manager.dart index 0e683a0..2ef4e80 100644 --- a/lib/src/config/config_manager.dart +++ b/lib/src/config/config_manager.dart @@ -77,6 +77,7 @@ class ConfigManager { Logger.info( '配置拉取并缓存成功: events=$eventCount, tags=$tagCount', ); + _logConfigDetails(info); } on DioException catch (e, st) { Logger.error('配置拉取失败(DioException)', e, st); } on Object catch (e, st) { @@ -135,4 +136,33 @@ class ConfigManager { } return null; } + + void _logConfigDetails(SystemDimInfo info) { + final events = info.eventDefinitions + .map( + (event) => event.eventName == null || event.eventName!.trim().isEmpty + ? event.eventCode + : '${event.eventCode}(${event.eventName})', + ) + .toList(growable: false); + final tags = info.tagDefinitions + .map( + (tag) => + '${tag.tagName}:${tag.tagType}${tag.isRequired ? '*' : ''}', + ) + .toList(growable: false); + + Logger.info( + '事件定义: ${events.isEmpty ? 'none' : events.join(', ')}', + ); + Logger.info( + '标签定义: ${tags.isEmpty ? 'none' : tags.join(', ')}', + ); + final strategy = info.sdkStrategy; + if (strategy != null) { + Logger.info( + '策略: enabled=${strategy.enabled}, defaultSampleRate=${strategy.defaultSampleRate}, eventSettings=${strategy.eventSettings.length}', + ); + } + } } diff --git a/lib/src/model/client_type.dart b/lib/src/model/client_type.dart new file mode 100644 index 0000000..102b559 --- /dev/null +++ b/lib/src/model/client_type.dart @@ -0,0 +1,19 @@ +/// 客户端类型枚举。 +enum ClientType { + /// 安卓 (1) + android(1), + + /// iOS (2) + ios(2), + + /// PC (3) + pc(3), + + /// H5 (4) + h5(4); + + const ClientType(this.value); + + /// 对应的整数值。 + final int value; +} diff --git a/lib/src/network/http_client.dart b/lib/src/network/http_client.dart index 93346f7..9778160 100644 --- a/lib/src/network/http_client.dart +++ b/lib/src/network/http_client.dart @@ -86,6 +86,9 @@ class HttpClient { } static String _normalizeBaseUrl(String baseUrl) { + if (baseUrl.endsWith('/') && baseUrl.length > 1) { + return baseUrl.substring(0, baseUrl.length - 1); + } return baseUrl; } } diff --git a/test/analytics_config_extra_test.dart b/test/analytics_config_extra_test.dart index 615ada0..fcd41c8 100644 --- a/test/analytics_config_extra_test.dart +++ b/test/analytics_config_extra_test.dart @@ -19,7 +19,7 @@ AnalyticsConfig _base({ }) { return AnalyticsConfig( systemCode: systemCode ?? 'SYS', - endpointBaseUrl: endpointBaseUrl ?? 'https://example.com/api', + endpointBaseUrl: endpointBaseUrl ?? 'https://example.com', clientType: clientType ?? 3, batchSize: batchSize ?? 20, flushInterval: flushInterval ?? 15, @@ -49,6 +49,13 @@ void main() { ); }); + test('包含路径的 baseUrl 会抛错', () { + expect( + () => _base(endpointBaseUrl: 'https://example.com/api').validate(), + throwsArgumentError, + ); + }); + test('非 https 会抛错', () { expect( () => _base(endpointBaseUrl: 'http://example.com').validate(), @@ -123,18 +130,36 @@ void main() { }); group('AnalyticsConfig.uri 组装', () { - test('basePath 为空时会补 /leaf', () { + test('基础 host 会拼接 api/ExternalEventlogs 路径', () { final config = _base(endpointBaseUrl: 'https://example.com'); - expect(config.addEventListLogUri.path, '/AddEventListLog'); - expect(config.addEventLogUri.path, '/AddEventLog'); - expect(config.getSystemAllDimInfoUri.path, '/GetSystemAllDimInfo'); + expect( + config.addEventListLogUri.path, + '/api/ExternalEventlogs/AddEventListLog', + ); + expect( + config.addEventLogUri.path, + '/api/ExternalEventlogs/AddEventLog', + ); + expect( + config.getSystemAllDimInfoUri.path, + '/api/ExternalEventlogs/GetSystemAllDimInfo', + ); }); - test('basePath 有值且带斜杠时会规范化', () { - final config = _base(endpointBaseUrl: 'https://example.com/api/'); - expect(config.addEventListLogUri.path, '/api/AddEventListLog'); - expect(config.addEventLogUri.path, '/api/AddEventLog'); - expect(config.getSystemAllDimInfoUri.path, '/api/GetSystemAllDimInfo'); + test('末尾带斜杠的 host 也可正常拼接', () { + final config = _base(endpointBaseUrl: 'https://example.com/'); + expect( + config.addEventListLogUri.path, + '/api/ExternalEventlogs/AddEventListLog', + ); + expect( + config.addEventLogUri.path, + '/api/ExternalEventlogs/AddEventLog', + ); + expect( + config.getSystemAllDimInfoUri.path, + '/api/ExternalEventlogs/GetSystemAllDimInfo', + ); }); }); } diff --git a/test/analytics_core_test.dart b/test/analytics_core_test.dart index 7e5042a..0cc7a76 100644 --- a/test/analytics_core_test.dart +++ b/test/analytics_core_test.dart @@ -290,7 +290,7 @@ AnalyticsConfig _testConfig({ }) { return AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, enableDebug: enableDebug, batchSize: batchSize, @@ -751,9 +751,9 @@ void main() { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; - const config = AnalyticsConfig( + final config = AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, enableDebug: true, batchSize: 10, @@ -879,9 +879,9 @@ void main() { schedulerFactory: _noopScheduler, ); - const config = AnalyticsConfig( + final config = AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, enableDebug: true, flushInterval: 3600, @@ -1206,9 +1206,9 @@ void main() { schedulerFactory: _noopScheduler, ); - const config = AnalyticsConfig( + final config = AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, flushInterval: 3600, metricsReportInterval: Duration(milliseconds: 10), diff --git a/test/api_client_test.dart b/test/api_client_test.dart index 98977aa..30b0c83 100644 --- a/test/api_client_test.dart +++ b/test/api_client_test.dart @@ -7,7 +7,7 @@ import 'package:yx_tracking_flutter/src/network/api_client.dart'; import 'package:yx_tracking_flutter/src/network/http_client.dart'; AnalyticsConfig _config() { - return const AnalyticsConfig( + return AnalyticsConfig( systemCode: 'SYS', endpointBaseUrl: 'https://example.com', clientType: 3, diff --git a/test/config_manager_test.dart b/test/config_manager_test.dart index 6aa636a..9cbaf61 100644 --- a/test/config_manager_test.dart +++ b/test/config_manager_test.dart @@ -76,9 +76,9 @@ class ThrowingHttpClient extends HttpClient { } AnalyticsConfig _config() { - return const AnalyticsConfig( + return AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, enableMetrics: false, ); diff --git a/test/facade_test.dart b/test/facade_test.dart index 2529622..30f2ef6 100644 --- a/test/facade_test.dart +++ b/test/facade_test.dart @@ -187,9 +187,9 @@ class _NoopScheduler extends Scheduler { } AnalyticsConfig _config() { - return const AnalyticsConfig( + return AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, enableDebug: true, metricsReportInterval: Duration(days: 1), diff --git a/test/http_client_test.dart b/test/http_client_test.dart index 20ed0fe..7b59bb4 100644 --- a/test/http_client_test.dart +++ b/test/http_client_test.dart @@ -59,7 +59,7 @@ void main() { test('headers 为空时不会创建 Options', () async { final adapter = _FakeAdapter(); final client = HttpClient( - _config('https://example.com/api'), + _config('https://example.com'), httpClientAdapter: adapter, ); @@ -71,7 +71,7 @@ void main() { test('会把 headers 透传到请求', () async { final adapter = _FakeAdapter(); final client = HttpClient( - _config('https://example.com/api'), + _config('https://example.com'), httpClientAdapter: adapter, ); diff --git a/test/validator_test.dart b/test/validator_test.dart index 3dca702..19e12c1 100644 --- a/test/validator_test.dart +++ b/test/validator_test.dart @@ -38,9 +38,9 @@ const DeviceInfo _device = DeviceInfo( ); AnalyticsConfig _config() { - return const AnalyticsConfig( + return AnalyticsConfig( systemCode: 'TEST_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, enableMetrics: false, ); diff --git a/test/yx_tracking_flutter_test.dart b/test/yx_tracking_flutter_test.dart index 72d29e7..ca82b1a 100644 --- a/test/yx_tracking_flutter_test.dart +++ b/test/yx_tracking_flutter_test.dart @@ -5,9 +5,9 @@ import 'package:yx_tracking_flutter/yx_tracking_flutter.dart'; void main() { group('AnalyticsConfig.validate', () { test('https 配置通过校验', () { - const config = AnalyticsConfig( + final config = AnalyticsConfig( systemCode: 'OA_APP', - endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'https://example.com', clientType: 3, ); @@ -15,9 +15,9 @@ void main() { }); test('非 https 配置会抛错', () { - const config = AnalyticsConfig( + final config = AnalyticsConfig( systemCode: 'OA_APP', - endpointBaseUrl: 'http://example.com/api/ExternalEventlogs', + endpointBaseUrl: 'http://example.com', clientType: 3, );