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:
parent
29c60bbc48
commit
3277efd47c
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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,121 +149,574 @@ 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();
|
||||
}
|
||||
await Analytics.flush(force: true);
|
||||
}
|
||||
|
||||
Future<void> _refreshConfig() async {
|
||||
if (_refreshingConfig) {
|
||||
return;
|
||||
await Analytics.refreshConfig(force: true);
|
||||
}
|
||||
|
||||
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'},
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_refreshingConfig = true;
|
||||
});
|
||||
try {
|
||||
await Analytics.refreshConfig(force: true);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_refreshingConfig = false;
|
||||
});
|
||||
}
|
||||
|
||||
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(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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: <Widget>[
|
||||
Text(
|
||||
'本地缓存事件数:$_cacheCount',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 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: _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('压力测试', <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),
|
||||
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: <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()),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue