Skip to content

Commit 97c9437

Browse files
feat(example): read QA config from dart-define instead of source patching (#168)
## Description Refactors the example app so QA smoke tests can inject `qa_run_id` session attributes and a startup `FaroUser` via `api-config.json` dart-define keys, removing the need to temporarily patch `main.dart` source code. Adds a `QaConfig` class that reads two optional keys at startup: - `FARO_QA_RUN_ID` — included in `sessionAttributes` when non-empty - `FARO_QA_INITIAL_USER_JSON` — parsed into a `FaroUser` for `initialUser` When absent or empty, the app behaves exactly as before. ## Type of Change - [x] 🚀 New feature (non-breaking change which adds functionality) - [x] 📝 Documentation ## Checklist - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the CHANGELOG.md under the "Unreleased" section ## Additional Notes - All changes are scoped to `example/` (plus docs in `AGENTS.md`) - No SDK (`lib/`) changes - 13 new unit tests covering: empty/missing config, valid user JSON, invalid JSON graceful fallback, non-string attribute coercion, combined run ID + user - CHANGELOG not updated since this is an example-app-only change — let me know if you'd like an entry added Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are isolated to the `example/` app and documentation, with defensive parsing and tests; no SDK or security-sensitive logic is modified. > > **Overview** > Adds opt-in QA smoke-test overrides to the example app via `--dart-define-from-file`, allowing tests to inject a `qa_run_id` session attribute and an initial `FaroUser` from `FARO_QA_INITIAL_USER_JSON` without editing source. > > Introduces `QaConfig` (with unit tests) to parse and validate these values (including graceful fallback on invalid JSON and attribute string coercion), wires it into `example/lib/main.dart`, and documents the new config keys in `AGENTS.md`, `example/README.md`, and `api-config.example.json`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cb7d785. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9d3e6ac commit 97c9437

File tree

6 files changed

+361
-11
lines changed

6 files changed

+361
-11
lines changed

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,22 @@ There is no Android emulator in this environment. To build the example APK:
259259
cd example && flutter build apk --dart-define-from-file api-config.json
260260
```
261261

262+
### QA smoke-test overrides
263+
264+
The example app reads optional QA dart-define keys (`FARO_QA_RUN_ID`, `FARO_QA_INITIAL_USER_JSON`) from `api-config.json` to inject session attributes and an initial user without patching source code. Include them in the config file:
265+
266+
```json
267+
{
268+
"FARO_COLLECTOR_URL": "https://...",
269+
"FARO_QA_RUN_ID": "smoke-123",
270+
"FARO_QA_INITIAL_USER_JSON": "{\"id\":\"qa-user\",\"username\":\"bot\"}"
271+
}
272+
```
273+
274+
Then run as usual: `flutter run --dart-define-from-file api-config.json`
275+
276+
See `example/README.md` § "QA Smoke Test Configuration" for the full reference.
277+
262278
### Before opening a PR
263279

264280
Run the pre-release check script before committing/opening a PR. It validates formatting, static analysis, tests, and CHANGELOG content in one step:

example/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,43 @@ Key implementation features shown in this example:
129129
- Error boundary setup
130130
- ANR detection configuration
131131

132+
## QA Smoke Test Configuration
133+
134+
The example app supports optional QA dart-define keys that inject session
135+
attributes and an initial user at startup, removing the need to patch source
136+
code for automated smoke tests.
137+
138+
| Key | Type | Purpose |
139+
|-----|------|---------|
140+
| `FARO_QA_RUN_ID` | string | Adds `qa_run_id` to session attributes |
141+
| `FARO_QA_INITIAL_USER_JSON` | stringified JSON | Sets the initial `FaroUser` |
142+
143+
Both keys are optional. When absent or empty, the app behaves normally.
144+
145+
All configuration lives in `api-config.json` and is passed via a single flag:
146+
147+
```bash
148+
flutter run --dart-define-from-file api-config.json
149+
```
150+
151+
### Example: api-config.json with QA fields
152+
153+
```json
154+
{
155+
"FARO_COLLECTOR_URL": "https://your-collector-url",
156+
"FARO_QA_RUN_ID": "smoke-test-12345",
157+
"FARO_QA_INITIAL_USER_JSON": "{\"id\":\"user-123\",\"username\":\"qa-bot\",\"email\":\"qa@test.com\",\"attributes\":{\"role\":\"tester\"}}"
158+
}
159+
```
160+
161+
The user JSON value must be a stringified JSON object (escaped quotes).
162+
Automation agents can produce this by JSON-encoding the user map into a string
163+
value.
164+
165+
Non-string attribute values (booleans, numbers) in the user JSON are
166+
automatically converted to strings to match the `FaroUser.attributes` contract.
167+
Invalid JSON is silently ignored, falling back to the normal initial user.
168+
132169
## Testing Features
133170

134171
The app provides interactive buttons to test various SDK features:

example/api-config.example.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
{
22
"_comment": "Grafana Cloud: Get URL from Frontend Observability > Your App > Web SDK Config. Self-hosted: Use your Grafana Alloy faro.receiver endpoint.",
3-
"FARO_COLLECTOR_URL": "https://faro-collector-prod-us-central-0.grafana.net/collect/YOUR_API_KEY"
3+
"FARO_COLLECTOR_URL": "https://faro-collector-prod-us-central-0.grafana.net/collect/YOUR_API_KEY",
4+
5+
"_qa_comment": "Optional QA fields below. Omit or leave empty for normal app behavior. The user JSON value must be a stringified JSON object (escaped quotes).",
6+
"FARO_QA_RUN_ID": "",
7+
"FARO_QA_INITIAL_USER_JSON": "",
8+
9+
"_qa_example_comment": "Example with QA values populated:",
10+
"_qa_example_FARO_QA_RUN_ID": "smoke-test-12345",
11+
"_qa_example_FARO_QA_INITIAL_USER_JSON": "{\"id\":\"user-123\",\"username\":\"qa-bot\",\"email\":\"qa@test.com\",\"attributes\":{\"role\":\"tester\"}}"
412
}

example/lib/main.dart

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@ import 'package:faro/faro.dart';
44
import 'package:faro_example/features/app_diagnostics/presentation/app_diagnostics_page.dart';
55
import 'package:faro_example/features/custom_telemetry/presentation/custom_telemetry_page.dart';
66
import 'package:faro_example/features/feature_catalog/presentation/feature_catalog_page.dart';
7+
import 'package:faro_example/features/network_requests/presentation/network_requests_page.dart';
78
import 'package:faro_example/features/sampling_settings/domain/sampling_settings_service.dart';
89
import 'package:faro_example/features/sampling_settings/presentation/sampling_settings_page.dart';
910
import 'package:faro_example/features/tracing/presentation/tracing_page.dart';
1011
import 'package:faro_example/features/user_actions/presentation/user_actions_page.dart';
1112
import 'package:faro_example/features/user_settings/user_settings_page.dart';
1213
import 'package:faro_example/features/user_settings/user_settings_service.dart';
14+
import 'package:faro_example/qa_config.dart';
1315
import 'package:flutter/material.dart';
1416
import 'package:flutter_riverpod/flutter_riverpod.dart';
1517
import 'package:shared_preferences/shared_preferences.dart';
16-
import 'package:faro_example/features/network_requests/presentation/network_requests_page.dart';
1718

1819
void main() async {
1920
WidgetsFlutterBinding.ensureInitialized();
@@ -46,6 +47,21 @@ void main() async {
4647
const faroCollectorUrl = String.fromEnvironment('FARO_COLLECTOR_URL');
4748
final faroApiKey = faroCollectorUrl.split('/').last;
4849

50+
final qaConfig = QaConfig.fromEnvironment();
51+
52+
final sessionAttributes = <String, Object>{
53+
'team': 'mobile',
54+
'department': 'engineering',
55+
'test_int': 42,
56+
'test_bool': true,
57+
'test_double': 3.14,
58+
if (qaConfig.hasRunId) 'qa_run_id': qaConfig.runId!,
59+
};
60+
61+
final initialUser = qaConfig.hasInitialUser
62+
? qaConfig.initialUser
63+
: userSettingsService.initialUser;
64+
4965
Faro().transports.add(OfflineTransport(
5066
maxCacheDuration: const Duration(days: 3),
5167
));
@@ -57,7 +73,6 @@ void main() async {
5773
appEnv: 'Test',
5874
apiKey: faroApiKey,
5975
namespace: 'flutter_app',
60-
// Sampling is configured via SamplingSettingsService
6176
sampling: samplingSettingsService.sampling,
6277
anrTracking: true,
6378
cpuUsageVitals: true,
@@ -66,14 +81,8 @@ void main() async {
6681
memoryUsageVitals: true,
6782
refreshRateVitals: true,
6883
fetchVitalsInterval: const Duration(seconds: 30),
69-
sessionAttributes: {
70-
'team': 'mobile',
71-
'department': 'engineering',
72-
'test_int': 42,
73-
'test_bool': true,
74-
'test_double': 3.14,
75-
},
76-
initialUser: userSettingsService.initialUser,
84+
sessionAttributes: sessionAttributes,
85+
initialUser: initialUser,
7786
persistUser: userSettingsService.persistUser,
7887
),
7988
appRunner: () async {

example/lib/qa_config.dart

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import 'dart:convert';
2+
3+
import 'package:faro/faro.dart';
4+
5+
/// Configuration parsed from optional QA dart-define keys.
6+
///
7+
/// Reads `FARO_QA_RUN_ID` and `FARO_QA_INITIAL_USER_JSON` from
8+
/// `String.fromEnvironment`, allowing QA smoke tests to inject
9+
/// session attributes and an initial user without patching source code.
10+
///
11+
/// Usage in production code:
12+
/// ```dart
13+
/// final qa = QaConfig.fromEnvironment();
14+
/// ```
15+
///
16+
/// Usage in tests (inject values directly):
17+
/// ```dart
18+
/// final qa = QaConfig.parse(qaRunId: 'run-42');
19+
/// ```
20+
class QaConfig {
21+
const QaConfig._({this.runId, this.initialUser});
22+
23+
/// A QA-assigned run identifier included in session attributes.
24+
final String? runId;
25+
26+
/// A [FaroUser] parsed from JSON, used as `initialUser` when present.
27+
final FaroUser? initialUser;
28+
29+
/// Whether a QA run ID was provided.
30+
bool get hasRunId => runId != null && runId!.isNotEmpty;
31+
32+
/// Whether a QA initial user was provided.
33+
bool get hasInitialUser => initialUser != null;
34+
35+
/// Reads QA config from compile-time dart-define environment variables.
36+
///
37+
/// Equivalent to `parse()` with defaults wired to `String.fromEnvironment`.
38+
static QaConfig fromEnvironment() {
39+
return parse(
40+
qaRunId: const String.fromEnvironment('FARO_QA_RUN_ID'),
41+
qaInitialUserJson:
42+
const String.fromEnvironment('FARO_QA_INITIAL_USER_JSON'),
43+
);
44+
}
45+
46+
/// Parses QA configuration from raw string values.
47+
///
48+
/// Empty strings are treated as "not provided".
49+
/// Invalid JSON in [qaInitialUserJson] is silently ignored (returns
50+
/// a config with no initial user).
51+
static QaConfig parse({
52+
String qaRunId = '',
53+
String qaInitialUserJson = '',
54+
}) {
55+
final runId = qaRunId.isNotEmpty ? qaRunId : null;
56+
final user = _parseUser(qaInitialUserJson);
57+
return QaConfig._(runId: runId, initialUser: user);
58+
}
59+
60+
static FaroUser? _parseUser(String json) {
61+
if (json.isEmpty) return null;
62+
63+
try {
64+
final decoded = jsonDecode(json);
65+
if (decoded is! Map<String, dynamic>) return null;
66+
67+
return FaroUser(
68+
id: decoded['id'] is String ? decoded['id'] as String : null,
69+
username: decoded['username'] is String
70+
? decoded['username'] as String
71+
: null,
72+
email: decoded['email'] is String ? decoded['email'] as String : null,
73+
attributes: _parseAttributes(decoded['attributes']),
74+
);
75+
} on FormatException {
76+
return null;
77+
}
78+
}
79+
80+
/// Converts an attributes map to `Map<String, String>`, coercing
81+
/// primitive values (bool, int, double) to their string representation.
82+
static Map<String, String>? _parseAttributes(dynamic raw) {
83+
if (raw == null) return null;
84+
if (raw is! Map) return null;
85+
86+
return raw.map<String, String>(
87+
(key, value) => MapEntry(key.toString(), value.toString()),
88+
);
89+
}
90+
}

0 commit comments

Comments
 (0)