feat: enhance example app, config & client type

- feat(example): add stress testing, error simulation & edge case UI
- feat(config): add ClientType enum with auto-detection
- refactor(config): centralize URL management & fix 404/400 errors
- test: update tests for non-const config & verify functionality
This commit is contained in:
Max 2026-02-03 17:03:26 +08:00
parent 29c60bbc48
commit 3277efd47c
17 changed files with 940 additions and 136 deletions

View File

@ -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;

View File

@ -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"}
}
]

View File

@ -31,7 +31,7 @@ Future<void> 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<void> bootstrapAnalytics() async {
```dart
await Analytics.track(
'PAGE_VIEW',
eventParams: const <String, dynamic>{'page': 'home'},
eventParams: const <String, dynamic>{
'Page': 'home',
'Url': 'https://example.com/home',
'ButtonId': 'page_view',
},
customTags: const <String, dynamic>{'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`

View File

@ -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 存储默认启用**,并支持失败降级到 sqfliteExample 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. **必填标签缺失**
- 后端配置必填 tagtrack 不带该字段。
- 期望Debug 模式 log warningRelease 模式打 `_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.

View File

@ -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<void> 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<DemoPage> {
int _cacheCount = 0;
List<RecentEventSummary> _recent = const <RecentEventSummary>[];
bool _flushing = false;
bool _refreshingConfig = false;
final List<String> _logs = <String>[];
final Set<String> _running = <String>{};
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<DemoPage> {
@override
void dispose() {
_pollTimer?.cancel();
_mockBaseUrlController.dispose();
_logScrollController.dispose();
super.dispose();
}
bool _isRunning(String key) => _running.contains(key);
Future<void> _runAction(
String key,
String label,
Future<void> 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<void> _refreshCount() async {
final results = await Future.wait(<Future<Object>>[
Analytics.cachedEventCount(),
@ -77,105 +149,313 @@ class _DemoPageState extends State<DemoPage> {
});
}
Map<String, dynamic> _requiredParams({
required String page,
required String url,
required String buttonId,
Map<String, dynamic>? extra,
}) {
final params = <String, dynamic>{
'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<void> _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<void> _trackDemoEvent() async {
await Analytics.track(
'DEMO_BUTTON_CLICK',
eventParams: const <String, dynamic>{
'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 <String, dynamic>{'tenantId': 't1', 'feature': 'demo'},
);
await _refreshCount();
}
Future<void> _flushNow() async {
if (_flushing) {
return;
}
setState(() {
_flushing = true;
});
try {
await Analytics.flush(force: true);
} finally {
if (mounted) {
setState(() {
_flushing = false;
});
}
await _refreshCount();
}
}
Future<void> _refreshConfig() async {
if (_refreshingConfig) {
return;
}
setState(() {
_refreshingConfig = true;
});
try {
await Analytics.refreshConfig(force: true);
} finally {
if (mounted) {
setState(() {
_refreshingConfig = false;
});
}
Future<void> _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: <String, dynamic>{'index': i},
),
customTags: const <String, dynamic>{'feature': 'stress_seq'},
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('YX Tracking Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
Future<void> _runStressConcurrent() async {
Future<void> 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: <String, dynamic>{'batch': batch, 'index': i},
),
customTags: const <String, dynamic>{'feature': 'stress_concurrent'},
);
}
}
final tasks = List<Future<void>>.generate(
10,
(index) => trackBatch(index, 100),
);
await Future.wait(tasks);
}
Future<void> _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: <String, dynamic>{
'ts': DateTime.now().millisecondsSinceEpoch,
},
),
customTags: const <String, dynamic>{'feature': 'continuous'},
),
);
});
final flushTimer = Timer.periodic(const Duration(seconds: 1), (_) {
unawaited(Analytics.flush(force: true));
});
while (DateTime.now().isBefore(endAt)) {
await Future<void>.delayed(const Duration(milliseconds: 500));
}
trackTimer.cancel();
flushTimer.cancel();
}
Future<void> _trackInvalidEvent() async {
await Analytics.track(
'DEMO_BUTTON_CLICK',
eventParams: _requiredParams(
page: 'demo_invalid',
url: 'https://example.com/invalid',
buttonId: 'demo_invalid',
),
customTags: const <String, dynamic>{'tenantId': 't1'},
);
}
Future<void> _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 <String, dynamic>{'case': 'timeout'},
),
);
await Analytics.flush(force: true);
await _reinitialize(original, reason: '恢复正常配置');
}
Future<void> _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 <String, dynamic>{'case': 'server_5xx'},
),
);
await Analytics.flush(force: true);
await _reinitialize(original, reason: '恢复正常配置');
}
Future<void> _trackEmptyParams() async {
await Analytics.track('EMPTY_PARAMS_EVENT');
}
Future<void> _trackLargePayload() async {
final payload = List<String>.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: <String, dynamic>{'payload': payload, 'size': payload.length},
),
);
}
Future<void> _trackSpecialChars() async {
await Analytics.track(
'SPECIAL_空格_😀_!@#',
eventParams: _requiredParams(
page: 'special_chars',
url: 'https://example.com/special',
buttonId: 'special_chars',
extra: const <String, dynamic>{'feature': 'special_chars'},
),
);
}
Future<void> _rapidInitDispose() async {
for (var i = 0; i < 5; i += 1) {
await Analytics.dispose();
await Analytics.init(_activeConfig);
}
}
Future<void> _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 <String, dynamic>{'case': 'expiration'},
),
);
await Future<void>.delayed(const Duration(seconds: 2));
await Analytics.flush(force: true);
await _reinitialize(original, reason: '恢复正常配置');
}
Widget _buildSection(String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'本地缓存事件数:$_cacheCount',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
ElevatedButton(
onPressed: _trackDemoEvent,
child: const Text('Track Demo Event'),
),
ElevatedButton(
onPressed: _flushing ? null : _flushNow,
child: Text(_flushing ? 'Flushing...' : 'Flush Now'),
),
OutlinedButton(
onPressed: _refreshingConfig ? null : _refreshConfig,
child: Text(
_refreshingConfig ? 'Refreshing...' : 'Refresh Config',
),
),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...children,
],
),
const SizedBox(height: 12),
const Text(
'说明:已对接 SDK-TEST-FLUTTER 系统,'
'点击 Track 按钮记录事件,点击 Flush 上报。',
),
const SizedBox(height: 12),
Text(
'最近事件(最多 20 条)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Expanded(
child: _recent.isEmpty
? const Text('暂无事件')
: ListView.separated(
);
}
Widget _buildConfigSummary() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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) {
@ -188,10 +468,255 @@ class _DemoPageState extends State<DemoPage> {
),
);
},
);
}
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: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Text(
'本地缓存事件数:$_cacheCount',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_buildSection('配置摘要', <Widget>[_buildConfigSummary()]),
_buildSection('基础操作', <Widget>[
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
ElevatedButton(
onPressed: _isRunning('track_demo')
? null
: () => _runAction(
'track_demo',
'Track Demo Event',
_trackDemoEvent,
),
child: const Text('Track Demo Event'),
),
ElevatedButton(
onPressed: _isRunning('flush')
? null
: () => _runAction('flush', 'Flush Now', _flushNow),
child: Text(
_isRunning('flush') ? 'Flushing...' : 'Flush Now',
),
),
OutlinedButton(
onPressed: _isRunning('refresh_config')
? null
: () => _runAction(
'refresh_config',
'Refresh Config',
_refreshConfig,
),
child: Text(
_isRunning('refresh_config')
? 'Refreshing...'
: 'Refresh Config',
),
),
],
),
const SizedBox(height: 8),
const Text(
'说明:已对接 SDK-TEST-FLUTTER 系统;若为 HTTP 联调,请保持 allowInsecureHttp=true。',
),
]),
_buildSection('压力测试', <Widget>[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('压力测试详细日志'),
subtitle: const Text('关闭后仅输出结果与异常'),
value: _verboseStressLogs,
onChanged: (value) {
setState(() {
_verboseStressLogs = value;
});
},
),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
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('错误模拟', <Widget>[
TextField(
controller: _mockBaseUrlController,
decoration: const InputDecoration(
labelText: '5xx/Mock BaseUrl可选',
hintText: 'http://localhost:8080',
),
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
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('边界用例', <Widget>[
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
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('状态与日志', <Widget>[
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()),
]),
],
),
);
}

View File

@ -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;
}
}

View File

@ -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}',
);
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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',
);
});
});
}

View File

@ -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),

View File

@ -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,

View File

@ -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,
);

View File

@ -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),

View File

@ -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,
);

View File

@ -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,
);

View File

@ -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,
);