From 60302c2e779f21d0d918ec639c583c37a364814c Mon Sep 17 00:00:00 2001 From: burhankhanzada Date: Thu, 22 Oct 2026 22:15:49 +0200 Subject: [PATCH 1/2] Count Windows and Linux users in analytics, and report the real version The Firebase SDK has no Windows/Linux implementation, so those installs were never counted. AnalyticsService now sends through an AnalyticsBackend: the Firebase SDK where it exists, and the GA4 Measurement Protocol on Windows/Linux, into the same property, under the same consent. An edition supplies the stream through TelemetryConfig.measurementProtocol. Also: - app_version came from a constant stuck at 0.0.1; it is read from the build. - signed_in user property; first_template_open, first_component_added and first_compile_attempt milestones. - User properties are set again when consent is given mid-session (they were dropped while analytics was off), and consent is applied before the answer is published so listeners see analytics already on. - Buffered analytics is flushed on exit, alongside OpenTelemetry. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 21 +- lib/app/bootstrap.dart | 8 +- lib/app/router.dart | 1 + lib/core/telemetry/analytics_backend.dart | 46 ++++ lib/core/telemetry/analytics_service.dart | 52 ++-- lib/core/telemetry/firebase_bootstrap.dart | 62 ++++- lib/core/telemetry/measurement_protocol.dart | 239 ++++++++++++++++++ lib/core/telemetry/otel_config.dart | 3 +- lib/core/telemetry/telemetry_consent.dart | 5 +- lib/core/telemetry/telemetry_context.dart | 21 +- lib/core/telemetry/telemetry_listener.dart | 45 +++- lib/core/telemetry/telemetry_support.dart | 8 + .../canvas/controller/canvas_controller.dart | 1 + .../providers/simulation_provider.dart | 3 + lib/main.dart | 11 +- .../pinbench_edition_api/lib/telemetry.dart | 21 +- .../telemetry/measurement_protocol_test.dart | 234 +++++++++++++++++ 17 files changed, 730 insertions(+), 51 deletions(-) create mode 100644 lib/core/telemetry/analytics_backend.dart create mode 100644 lib/core/telemetry/measurement_protocol.dart create mode 100644 test/core/telemetry/measurement_protocol_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 194f6e0..448a83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,26 @@ to it. Anything older is summarised at the end. ## [Unreleased] -Nothing yet. +### Changed + +- **Usage statistics now work on Windows and Linux too**, for those who agree + to share them. They go to the same place as on macOS and the web, under the + same answer in Settings ▸ Privacy. + +### Fixed + +- Usage statistics reported every build as version 0.0.1; they now carry the + real version. + +### Internal + +- Analytics: `AnalyticsService` sends through an `AnalyticsBackend` — the + Firebase SDK, or the GA4 Measurement Protocol on Windows/Linux + (`measurement_protocol.dart`, configured by an edition's + `TelemetryConfig.measurementProtocol`). New `signed_in` user property; new + `first_template_open`, `first_component_added` and `first_compile_attempt` + milestones; user properties are set again when consent is given mid-session, + and buffered analytics is flushed on exit. ## [0.1.2] - 2026-10-04 diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index d7d034d..ddc3bab 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -110,14 +110,17 @@ void installCloudLogSink() { Future setupFirebase({ required TelemetryConfig? config, required TelemetryConsent consent, + required SharedPreferences prefs, + required String version, }) => initFirebaseTelemetry( config: config, granted: consent == TelemetryConsent.granted, + prefs: prefs, // Set whenever analytics starts sending, not at startup: until the user // agrees there is nothing to set them on. onAnalyticsOn: (analytics) => analytics ..setAppContext(platform: runPlatform) - ..setAppVersion(appVersion), + ..setAppVersion(version), ); /// Brings up OpenTelemetry (native only — its OTLP exporter needs `dart:io`). @@ -133,8 +136,9 @@ Future setupFirebase({ /// so there is nothing to ask about. Future<({TracingService tracing, TelemetryControl? control})> setupTracing({ required bool granted, + required String version, }) async { - final config = OtelConfig.fromEnvironment(platform: runPlatform); + final config = OtelConfig.fromEnvironment(platform: runPlatform, serviceVersion: version); if (kIsWeb || !config.isConfigured) { return (tracing: const TracingService.disabled(), control: null); } diff --git a/lib/app/router.dart b/lib/app/router.dart index 8953308..8e11217 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -212,6 +212,7 @@ class _TemplateViewState extends ConsumerState<_TemplateView> { /// spinner — keeps working even after the original caller is gone. Future openTemplateWorkspace(ProviderContainer container, String template) async { container.read(analyticsProvider).templateOpened(template); + container.read(milestonesProvider).fireOnce('first_template_open'); // Show the loading overlay from the moment the template is picked, covering // the asset-loading phase too (openWorkspace's own begin/end nests under this). container.read(workspaceLoadingProvider.notifier).begin(); diff --git a/lib/core/telemetry/analytics_backend.dart b/lib/core/telemetry/analytics_backend.dart new file mode 100644 index 0000000..d141c65 --- /dev/null +++ b/lib/core/telemetry/analytics_backend.dart @@ -0,0 +1,46 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; + +/// Where an `AnalyticsService` sends: the Firebase SDK where there is one, and +/// the Measurement Protocol on Windows and Linux, where there is not (see +/// `measurement_protocol.dart`). Both land in the same Google Analytics +/// property, so users on every platform are counted together. +abstract interface class AnalyticsBackend { + Future logEvent(String name, Map? params); + + Future logScreenView(String screenName); + + Future setUserProperty(String name, String value); + + /// Consent mode. Ad storage is always denied: the app shows no ads. + Future setConsent({required bool granted}); + + /// Sends anything buffered. Called before the app exits. + Future flush(); +} + +/// [AnalyticsBackend] over the Firebase Analytics SDK. +class const FirebaseAnalyticsBackend(final FirebaseAnalytics _analytics) + implements AnalyticsBackend { + @override + Future logEvent(String name, Map? params) => + _analytics.logEvent(name: name, parameters: params); + + @override + Future logScreenView(String screenName) => _analytics.logScreenView(screenName: screenName); + + @override + Future setUserProperty(String name, String value) => + _analytics.setUserProperty(name: name, value: value); + + @override + Future setConsent({required bool granted}) => _analytics.setConsent( + analyticsStorageConsentGranted: granted, + adStorageConsentGranted: false, + adUserDataConsentGranted: false, + adPersonalizationSignalsConsentGranted: false, + ); + + /// The SDK batches and sends on its own, and persists what it has not sent. + @override + Future flush() async {} +} diff --git a/lib/core/telemetry/analytics_service.dart b/lib/core/telemetry/analytics_service.dart index 2e772d7..3983019 100644 --- a/lib/core/telemetry/analytics_service.dart +++ b/lib/core/telemetry/analytics_service.dart @@ -1,22 +1,22 @@ import 'dart:async'; -import 'package:firebase_analytics/firebase_analytics.dart'; - import '../utils/logger.dart'; +import 'analytics_backend.dart'; -/// Thin wrapper over [FirebaseAnalytics] that logs the app's meaningful +/// Thin wrapper over an [AnalyticsBackend] — the Firebase Analytics SDK, or +/// the Measurement Protocol on Windows/Linux — that logs the app's meaningful /// interactions — navigation, feature usage, and button/menu actions. /// -/// When Firebase is unavailable (unsupported platform, or `flutterfire -/// configure` hasn't been run yet) [_analytics] is null and every method is a -/// safe no-op, so call sites never need their own guards. All logging is +/// When there is no backend (no telemetry in this build, or the user has not +/// agreed) [_analytics] is null and every method is a safe no-op, so call +/// sites never need their own guards. All logging is /// fire-and-forget and swallows errors: telemetry must never break the app. /// /// Event/parameter names follow Firebase Analytics limits (snake_case, event /// name ≤ 40 chars, string values ≤ 100 chars). Parameter values must be /// `String` or `num` (booleans are encoded as 0/1). class AnalyticsService { - const new(FirebaseAnalytics analytics) : _fixed = analytics, _slot = null; + const new(AnalyticsBackend analytics) : _fixed = analytics, _slot = null; /// A disabled instance: every call is a no-op. Used as the provider default /// and whenever Firebase init is skipped or fails. @@ -27,10 +27,10 @@ class AnalyticsService { /// when the user agrees, and empties it again if they withdraw. const new switchable(AnalyticsSlot slot) : _fixed = null, _slot = slot; - final FirebaseAnalytics? _fixed; + final AnalyticsBackend? _fixed; final AnalyticsSlot? _slot; - FirebaseAnalytics? get _analytics => _fixed ?? _slot?.instance; + AnalyticsBackend? get _analytics => _fixed ?? _slot?.instance; /// Whether events are being sent right now. For a switchable service this /// follows the user's consent. @@ -42,7 +42,7 @@ class AnalyticsService { void logEvent(String name, [Map? params]) { final analytics = _analytics; if (analytics == null) return; - _guard(() => analytics.logEvent(name: name, parameters: params)); + _guard(() => analytics.logEvent(name, params)); } /// Logs a screen/route view. [screenName] must be a normalized name (e.g. @@ -50,7 +50,7 @@ class AnalyticsService { void logScreen(String screenName) { final analytics = _analytics; if (analytics == null) return; - _guard(() => analytics.logScreenView(screenName: screenName)); + _guard(() => analytics.logScreenView(screenName)); } /// Sets a user property for segmentation (persists across events for the @@ -59,7 +59,7 @@ class AnalyticsService { final analytics = _analytics; if (analytics == null) return; final trimmed = value.length <= 36 ? value : value.substring(0, 36); - _guard(() => analytics.setUserProperty(name: name, value: trimmed)); + _guard(() => analytics.setUserProperty(name, trimmed)); } /// Records which app the user is running against (web vs desktop, etc.). @@ -70,14 +70,19 @@ class AnalyticsService { void setConsent({required bool granted}) { final analytics = _analytics; if (analytics == null) return; - _guard( - () => analytics.setConsent( - analyticsStorageConsentGranted: granted, - adStorageConsentGranted: false, - adUserDataConsentGranted: false, - adPersonalizationSignalsConsentGranted: false, - ), - ); + _guard(() => analytics.setConsent(granted: granted)); + } + + /// Sends anything the backend has buffered. Awaited before the app exits; + /// like every other call here, it never throws. + Future flush() async { + final analytics = _analytics; + if (analytics == null) return; + try { + await analytics.flush(); + } catch (e) { + talker.warning('Analytics flush failed: $e'); + } } // ── Segmentation user properties ──────────────────────────────────────────── @@ -91,6 +96,11 @@ class AnalyticsService { /// The app version, so behaviour can be sliced across releases. void setAppVersion(String version) => setUserProperty('app_version', version); + /// Whether the user is signed in to an account (`yes` / `no`). Never the + /// account itself. + void setSignedIn({required bool signedIn}) => + setUserProperty('signed_in', signedIn ? 'yes' : 'no'); + // ── Named feature events (the app's vocabulary) ───────────────────────────── /// A top-level UI action: button, menu item, toolbar control, etc. @@ -195,5 +205,5 @@ class AnalyticsService { /// Holds the SDK instance a [AnalyticsService.switchable] sends through; null /// while analytics is off. class AnalyticsSlot { - FirebaseAnalytics? instance; + AnalyticsBackend? instance; } diff --git a/lib/core/telemetry/firebase_bootstrap.dart b/lib/core/telemetry/firebase_bootstrap.dart index 83a23f7..afbc06b 100644 --- a/lib/core/telemetry/firebase_bootstrap.dart +++ b/lib/core/telemetry/firebase_bootstrap.dart @@ -8,11 +8,15 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_performance/firebase_performance.dart'; import 'package:pinbench_edition_api/telemetry.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../utils/logger.dart'; +import 'analytics_backend.dart'; import 'analytics_service.dart'; import 'crash_reporter.dart'; +import 'measurement_protocol.dart'; import 'telemetry_consent.dart'; +import 'telemetry_context.dart'; import 'telemetry_support.dart'; /// The Firebase-backed telemetry the app talks to: analytics events + crash @@ -46,18 +50,34 @@ class const FirebaseTelemetry({ /// their mind. [onAnalyticsOn] runs each time analytics starts sending, to set /// its user properties then rather than while it is off. /// +/// On Windows and Linux, where there is no Firebase SDK, analytics goes over +/// the Measurement Protocol instead (see `measurement_protocol.dart`), with the +/// same consent rules; there is no crash reporting there beyond OpenTelemetry. +/// [prefs] keeps that install's client id. +/// /// Best-effort throughout: a failure here returns disabled handles and the app /// runs exactly as before. Call **after** [initAppLogger] so the existing /// Talker error handler is chained ahead of Crashlytics. Future initFirebaseTelemetry({ required TelemetryConfig? config, required bool granted, + required SharedPreferences prefs, required void Function(AnalyticsService analytics) onAnalyticsOn, }) async { - if (config == null || !TelemetrySupport.firebase) return FirebaseTelemetry.disabled; + if (config == null) return FirebaseTelemetry.disabled; + if (TelemetrySupport.measurementProtocol) { + return _measurementProtocolTelemetry( + config, + granted: granted, + prefs: prefs, + onAnalyticsOn: onAnalyticsOn, + ); + } + final options = config.firebase; + if (options == null || !TelemetrySupport.firebase) return FirebaseTelemetry.disabled; try { - await Firebase.initializeApp(options: config.firebase); + await Firebase.initializeApp(options: options); } catch (e) { talker.warning('Firebase telemetry disabled (init skipped): $e'); return FirebaseTelemetry.disabled; @@ -80,6 +100,35 @@ Future initFirebaseTelemetry({ ); } +/// Analytics alone, over the Measurement Protocol; Firebase is never +/// initialised, so Remote Config keeps its defaults too. +FirebaseTelemetry _measurementProtocolTelemetry( + TelemetryConfig config, { + required bool granted, + required SharedPreferences prefs, + required void Function(AnalyticsService analytics) onAnalyticsOn, +}) { + final mp = config.measurementProtocol; + if (mp == null) return FirebaseTelemetry.disabled; + final control = MeasurementProtocolControl( + config.privacyPolicyUrl, + prefs: prefs, + onAnalyticsOn: onAnalyticsOn, + createBackend: (clientId) => MeasurementProtocolBackend( + config: mp, + clientId: clientId, + platform: runPlatform, + language: PlatformDispatcher.instance.locale.toLanguageTag(), + ), + )..applyConsent(granted: granted); + return FirebaseTelemetry( + analytics: control.analytics, + crashReporter: const CrashReporter.disabled(), + control: control, + initialized: false, + ); +} + /// Switches every Firebase SDK's collection on or off together. /// /// Crash reports and performance traces are never sent from a debug build, @@ -116,13 +165,16 @@ class _FirebaseTelemetryControl( void _analyticsConsent(bool granted) { if (!TelemetrySupport.analytics) return; if (granted) { - final instance = _analyticsSlot.instance ??= FirebaseAnalytics.instance; + final instance = FirebaseAnalytics.instance; + _analyticsSlot.instance ??= FirebaseAnalyticsBackend(instance); unawaited(instance.setAnalyticsCollectionEnabled(true).catchError((Object _) {})); analytics.setConsent(granted: true); _onAnalyticsOn(analytics); - } else if (_analyticsSlot.instance case final instance?) { + } else if (_analyticsSlot.instance != null) { analytics.setConsent(granted: false); - unawaited(instance.setAnalyticsCollectionEnabled(false).catchError((Object _) {})); + unawaited( + FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false).catchError((Object _) {}), + ); _analyticsSlot.instance = null; } } diff --git a/lib/core/telemetry/measurement_protocol.dart b/lib/core/telemetry/measurement_protocol.dart new file mode 100644 index 0000000..211cfcc --- /dev/null +++ b/lib/core/telemetry/measurement_protocol.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart' show kDebugMode; + +import 'package:http/http.dart' as http; +import 'package:pinbench_edition_api/telemetry.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/logger.dart'; +import 'analytics_backend.dart'; +import 'analytics_service.dart'; +import 'telemetry_consent.dart'; + +/// Analytics on Windows and Linux, which the Firebase SDK does not support: +/// events go to a Google Analytics 4 web data stream over the Measurement +/// Protocol, a plain HTTPS endpoint, so these installs are counted as users +/// in the same property as every other platform. +/// +/// What the SDK does on its own is done here by hand: +/// +/// * **The user** is a random client id kept in [SharedPreferences] — the +/// install, not the person. A new one also sends [firstLaunchEvent], since +/// the protocol may not send `first_visit` itself. +/// * **Sessions**: a `session_id` that renews after 30 minutes without an +/// event, and `engagement_time_msec` on every event. Without both, GA4 does +/// not count the user as active. +/// * **Batching**: events wait up to [flushDelay], or until there are 25 (the +/// protocol's limit per request), and [flush] sends the rest before exit. +/// +/// Screens are sent as `page_view`: a web stream refuses `screen_view`. +/// +/// Debug builds post to the validation endpoint, which checks each request and +/// records nothing, and log what it objects to. +class MeasurementProtocolBackend implements AnalyticsBackend { + new({ + required this._config, + required this._clientId, + required this._platform, + required this._language, + http.Client? client, + DateTime Function()? clock, + this.flushDelay = const Duration(seconds: 10), + this._validateOnly = kDebugMode, + }) : _client = client ?? http.Client(), + _clock = clock ?? DateTime.now; + + /// Logged the first time an install sends anything. + static const firstLaunchEvent = 'first_launch'; + + /// The protocol's limit on events in one request. + static const _batchSize = 25; + + static const _sessionTimeout = Duration(minutes: 30); + + /// The most engagement one event can claim. The time since the previous + /// event includes time spent away from the app, so a long gap would + /// otherwise count as a long stretch of use. + static const _maxEngagement = Duration(minutes: 1); + + final MeasurementProtocolConfig _config; + final String _clientId; + final String _platform; + final String _language; + final http.Client _client; + final DateTime Function() _clock; + final bool _validateOnly; + + /// How long an event may wait to be batched with others. + final Duration flushDelay; + + final _userProperties = {}; + final _pending = >[]; + Timer? _timer; + int? _sessionId; + DateTime? _lastEventAt; + + /// The events waiting to be sent. Visible for tests. + List> get pending => List.unmodifiable(_pending); + + @override + Future logEvent(String name, Map? params) async => + _enqueue(name, params ?? const {}); + + @override + Future logScreenView(String screenName) async => _enqueue('page_view', { + 'page_title': screenName, + 'page_location': 'app://pinbench/$screenName', + }); + + @override + Future setUserProperty(String name, String value) async => _userProperties[name] = value; + + /// Withdrawing consent drops what has not been sent yet: it was collected + /// with consent, but the user has just said not to send it. + @override + Future setConsent({required bool granted}) async { + if (granted) return; + _timer?.cancel(); + _pending.clear(); + } + + @override + Future flush() async { + _timer?.cancel(); + while (_pending.isNotEmpty) { + final batch = _pending.take(_batchSize).toList(); + _pending.removeRange(0, batch.length); + await _send(batch); + } + } + + void _enqueue(String name, Map params) { + final now = _clock(); + final last = _lastEventAt; + final sessionId = last == null || now.difference(last) > _sessionTimeout || _sessionId == null + ? now.millisecondsSinceEpoch ~/ 1000 + : _sessionId!; + final engaged = sessionId == _sessionId && last != null ? now.difference(last) : Duration.zero; + _sessionId = sessionId; + _lastEventAt = now; + + _pending.add({ + 'name': name, + 'params': { + ...params, + 'session_id': sessionId, + // Must be above zero for the user to count as active. + 'engagement_time_msec': max(1, min(engaged.inMilliseconds, _maxEngagement.inMilliseconds)), + }, + }); + if (_pending.length >= _batchSize) { + unawaited(flush()); + } else { + _timer ??= Timer(flushDelay, () { + _timer = null; + unawaited(flush()); + }); + } + } + + Future _send(List> events) async { + final uri = Uri.https( + 'www.google-analytics.com', + _validateOnly ? '/debug/mp/collect' : '/mp/collect', + {'measurement_id': _config.measurementId, 'api_secret': _config.apiSecret}, + ); + final body = jsonEncode({ + 'client_id': _clientId, + if (_userProperties.isNotEmpty) + 'user_properties': { + for (final MapEntry(:key, :value) in _userProperties.entries) key: {'value': value}, + }, + // Only ever sent with the user's consent to analytics; there are no ads. + 'consent': {'ad_user_data': 'DENIED', 'ad_personalization': 'DENIED'}, + 'device': { + 'category': 'desktop', + 'operating_system': _operatingSystem, + 'language': _language, + }, + 'events': events, + }); + try { + final response = await _client.post( + uri, + headers: {'content-type': 'application/json'}, + body: body, + ); + if (_validateOnly) { + talker.debug('Measurement Protocol validation: ${response.body}'); + } else if (response.statusCode >= 300) { + talker.warning('Measurement Protocol answered ${response.statusCode}'); + } + } catch (e) { + // Offline, or the request failed: the batch is dropped rather than + // kept, so an install that is never online does not grow a backlog. + talker.warning('Measurement Protocol send failed: $e'); + } + } + + /// GA4's spelling of the operating system, from the app's platform label. + String get _operatingSystem => switch (_platform) { + 'windows' => 'Windows', + 'linux' => 'Linux', + 'macOS' => 'Macintosh', + final other => other, + }; +} + +/// The consent switch for [MeasurementProtocolBackend]: no backend — and no +/// client id — exists until the user agrees, and withdrawing drops it again. +class MeasurementProtocolControl implements TelemetryControl { + new( + this.privacyPolicyUrl, { + required this._createBackend, + required this._prefs, + required this._onAnalyticsOn, + }); + + static const _clientIdKey = 'analytics.client_id'; + + @override + final String privacyPolicyUrl; + + final MeasurementProtocolBackend Function(String clientId) _createBackend; + final SharedPreferences _prefs; + final void Function(AnalyticsService analytics) _onAnalyticsOn; + + final _slot = AnalyticsSlot(); + late final analytics = AnalyticsService.switchable(_slot); + + @override + bool get available => true; + + @override + void applyConsent({required bool granted}) { + if (granted) { + if (_slot.instance != null) return; + final stored = _prefs.getString(_clientIdKey); + final clientId = stored ?? _newClientId(); + if (stored == null) unawaited(_prefs.setString(_clientIdKey, clientId)); + _slot.instance = _createBackend(clientId); + analytics.setConsent(granted: true); + _onAnalyticsOn(analytics); + if (stored == null) analytics.logEvent(MeasurementProtocolBackend.firstLaunchEvent); + } else if (_slot.instance != null) { + analytics.setConsent(granted: false); + _slot.instance = null; + } + } + + /// Random, in the shape of the id gtag.js keeps in its cookie. + static String _newClientId() { + final random = Random.secure(); + final seconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return '${random.nextInt(1 << 31)}.$seconds'; + } +} diff --git a/lib/core/telemetry/otel_config.dart b/lib/core/telemetry/otel_config.dart index 70429aa..f993ee2 100644 --- a/lib/core/telemetry/otel_config.dart +++ b/lib/core/telemetry/otel_config.dart @@ -1,7 +1,6 @@ // Credentials are supplied via --dart-define (the standard OTEL_* mechanism), so // String.fromEnvironment is intentional here. // ignore_for_file: do_not_use_environment -import 'telemetry_context.dart'; /// Configuration for the OpenTelemetry (Grafana Cloud) exporter. /// @@ -31,7 +30,7 @@ class const OtelConfig({ }) { /// Builds config from `--dart-define` values. [platform] is a coarse label /// (e.g. `macos`, `windows`) recorded as a resource attribute. - factory fromEnvironment({required String platform, String serviceVersion = appVersion}) { + factory fromEnvironment({required String platform, required String serviceVersion}) { const endpoint = String.fromEnvironment('OTEL_EXPORTER_OTLP_ENDPOINT'); const rawHeaders = String.fromEnvironment('OTEL_EXPORTER_OTLP_HEADERS'); const serviceName = String.fromEnvironment('OTEL_SERVICE_NAME', defaultValue: 'pinbench'); diff --git a/lib/core/telemetry/telemetry_consent.dart b/lib/core/telemetry/telemetry_consent.dart index af7f502..a529975 100644 --- a/lib/core/telemetry/telemetry_consent.dart +++ b/lib/core/telemetry/telemetry_consent.dart @@ -61,10 +61,11 @@ class TelemetryConsentController extends Notifier { @override TelemetryConsent build() => readTelemetryConsent(ref.read(sharedPreferencesProvider)); - /// Records the answer and applies it immediately. + /// Records the answer and applies it immediately — applied first, so whatever + /// listens for the answer finds analytics already switched on or off. Future choose({required bool granted}) async { - state = granted ? TelemetryConsent.granted : TelemetryConsent.denied; ref.read(telemetryControlProvider).applyConsent(granted: granted); + state = granted ? TelemetryConsent.granted : TelemetryConsent.denied; await ref.read(sharedPreferencesProvider).setString(_prefsKey, state.name); } } diff --git a/lib/core/telemetry/telemetry_context.dart b/lib/core/telemetry/telemetry_context.dart index 01f30cb..80202db 100644 --- a/lib/core/telemetry/telemetry_context.dart +++ b/lib/core/telemetry/telemetry_context.dart @@ -1,10 +1,21 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; -/// App version reported as an Analytics user property and an OpenTelemetry -/// resource attribute. Keep in sync with the `version:` in pubspec.yaml — -/// this can't just read it at runtime because pubspec fields aren't bundled -/// into the compiled app without an extra codegen step. -const appVersion = '0.0.1'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// The running build's version, reported as an Analytics user property and an +/// OpenTelemetry resource attribute. +/// +/// Read from the build itself rather than kept as a constant: release builds +/// stamp it with `--build-name`, and a constant here went stale at `0.0.1` +/// while the app moved on. `unknown` where the platform cannot say (a plain +/// `flutter test`). +Future readAppVersion() async { + try { + return (await PackageInfo.fromPlatform()).version; + } catch (_) { + return 'unknown'; + } +} /// A coarse label for where the app is running (`web`, `macos`, `windows`, /// …), attached to both Analytics events and OpenTelemetry spans so usage diff --git a/lib/core/telemetry/telemetry_listener.dart b/lib/core/telemetry/telemetry_listener.dart index 68d0770..d26a125 100644 --- a/lib/core/telemetry/telemetry_listener.dart +++ b/lib/core/telemetry/telemetry_listener.dart @@ -4,16 +4,20 @@ import 'dart:ui' show AppExitResponse; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pinbench_cloud/auth/auth_user.dart'; import 'package:pinbench_sim/models/simulation_state.dart'; import 'package:pinbench_ui/theme/theme.dart'; import '../../features/editor/providers/editor_provider.dart'; import '../../features/simulation/providers/simulation_provider.dart'; +import '../auth/auth_provider.dart'; +import 'telemetry_consent.dart'; import 'telemetry_providers.dart'; /// Wraps the app and turns cross-cutting state changes into telemetry: -/// navigation, theme, simulation-state crash context, and a flush of buffered -/// OpenTelemetry spans before the app exits or is backgrounded. +/// navigation, theme and sign-in user properties, simulation-state crash +/// context, and a flush of buffered analytics events and OpenTelemetry spans +/// before the app exits or is backgrounded. /// /// Explicit actions (buttons, menu items, feature usage) are logged at their /// call sites via [analyticsProvider] / [tracingProvider]; this holds only the @@ -34,18 +38,31 @@ class _TelemetryListenerState extends ConsumerState { @override void initState() { super.initState(); - // OpenTelemetry batches spans (~5s), so the last batch can be lost on quit. - // Flush on a cancelable exit (desktop ⌘Q / window close) and on pause - // (mobile backgrounding). No-op when tracing is disabled (web / unconfigured). + // OpenTelemetry batches spans (~5s), and analytics on Windows/Linux batches + // events (~10s), so the last batch can be lost on quit. Flush on a + // cancelable exit (desktop ⌘Q / window close) and on pause (mobile + // backgrounding). No-op where neither batches. _lifecycle = AppLifecycleListener( onExitRequested: () async { - await ref.read(tracingProvider).flush(); + await _flush(); return AppExitResponse.exit; }, - onPause: () => unawaited(ref.read(tracingProvider).flush()), + onPause: () => unawaited(_flush()), ); - // Seed the initial theme user property (ref.listen only fires on change). - ref.read(analyticsProvider).setTheme(ref.read(themeModeProvider).name); + _seedUserProperties(); + } + + Future _flush() => + (ref.read(analyticsProvider).flush(), ref.read(tracingProvider).flush()).wait; + + /// The user properties that `ref.listen` only sets on change. Set here at + /// start, and again when the user agrees to analytics — anything set while + /// it was off went nowhere. + void _seedUserProperties() { + final analytics = ref.read(analyticsProvider)..setTheme(ref.read(themeModeProvider).name); + if (ref.read(authStateProvider) case AsyncData(:final value)) { + analytics.setSignedIn(signedIn: value != null); + } } @override @@ -84,6 +101,16 @@ class _TelemetryListenerState extends ConsumerState { ref.read(crashReporterProvider).setKey('theme', mode.name); }); + ref.listen>(authStateProvider, (_, next) { + if (next case AsyncData(:final value)) { + ref.read(analyticsProvider).setSignedIn(signedIn: value != null); + } + }); + + ref.listen(telemetryConsentProvider, (previous, next) { + if (next == TelemetryConsent.granted && previous != next) _seedUserProperties(); + }); + ref.listen(simulationProvider, (_, state) { ref.read(crashReporterProvider).setKey('sim_state', state.name); }); diff --git a/lib/core/telemetry/telemetry_support.dart b/lib/core/telemetry/telemetry_support.dart index ee16522..9f6e8d9 100644 --- a/lib/core/telemetry/telemetry_support.dart +++ b/lib/core/telemetry/telemetry_support.dart @@ -18,6 +18,14 @@ abstract final class TelemetrySupport { /// Analytics: Android, iOS, macOS, Web. (No Windows/Linux.) static bool get analytics => kIsWeb || _isMacOS || _isAndroid || _isIOS; + /// Analytics over the Measurement Protocol instead of the SDK: the platforms + /// [analytics] leaves out, Windows and Linux. Plain HTTPS, so it works + /// anywhere; it is only used where there is no SDK. + static bool get measurementProtocol => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux); + /// Crashlytics: Android, iOS, macOS. (No Web/Windows/Linux implementation.) static bool get crashlytics => !kIsWeb && (_isMacOS || _isAndroid || _isIOS); diff --git a/lib/features/canvas/controller/canvas_controller.dart b/lib/features/canvas/controller/canvas_controller.dart index cc18857..4fdd935 100644 --- a/lib/features/canvas/controller/canvas_controller.dart +++ b/lib/features/canvas/controller/canvas_controller.dart @@ -178,6 +178,7 @@ class CanvasController() return; } ref.read(analyticsProvider).componentAdded(child.part.definitionId ?? child.part.name); + ref.read(milestonesProvider).fireOnce('first_component_added'); executeCommand(AddNodeCommand(child)); } diff --git a/lib/features/simulation/providers/simulation_provider.dart b/lib/features/simulation/providers/simulation_provider.dart index 1b2f9cb..525d292 100644 --- a/lib/features/simulation/providers/simulation_provider.dart +++ b/lib/features/simulation/providers/simulation_provider.dart @@ -167,6 +167,9 @@ class Simulation extends _$Simulation { codeBytes: code.length, errorType: success ? null : diagnostics.compileErrorType, ); + // Whether it compiled or not: with first_simulation_success, the + // tried-then-succeeded step of the activation funnel. + ref.read(milestonesProvider).fireOnce('first_compile_attempt'); if (success) { state = SimulationState.running; diff --git a/lib/main.dart b/lib/main.dart index c119364..8f987ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'core/remote_config/remote_config_bootstrap.dart'; import 'core/services/feedback_service.dart'; import 'core/telemetry/analytics_milestones.dart'; import 'core/telemetry/telemetry_consent.dart'; +import 'core/telemetry/telemetry_context.dart'; import 'core/utils/logger.dart'; import 'shell/window.dart'; @@ -29,12 +30,18 @@ void main() async { final storage = await setupLocalStorage(); final consent = readTelemetryConsent(storage.sharedPreferences); final granted = consent == TelemetryConsent.granted; - final firebase = await setupFirebase(config: edition?.telemetry, consent: consent); + final version = await readAppVersion(); + final firebase = await setupFirebase( + config: edition?.telemetry, + consent: consent, + prefs: storage.sharedPreferences, + version: version, + ); // Independent of one another, so together: startup waits for the slowest // (a Remote Config fetch, a cloud session restore), not for the sum. final (featureFlags, tracingSetup, editionSetup) = await ( setupRemoteConfig(firebaseReady: firebase.initialized, granted: granted), - setupTracing(granted: granted), + setupTracing(granted: granted, version: version), setupEdition(edition), ).wait; final tracing = tracingSetup.tracing; diff --git a/packages/pinbench_edition_api/lib/telemetry.dart b/packages/pinbench_edition_api/lib/telemetry.dart index 4b9da62..5c594aa 100644 --- a/packages/pinbench_edition_api/lib/telemetry.dart +++ b/packages/pinbench_edition_api/lib/telemetry.dart @@ -10,9 +10,26 @@ import 'package:flutter/foundation.dart' show immutable; @immutable class const TelemetryConfig({ /// The Firebase project analytics, crash reports, performance traces and - /// remote config belong to. - required final FirebaseOptions firebase, + /// remote config belong to. Null on a platform Firebase has no SDK for. + final FirebaseOptions? firebase, + + /// Where analytics goes on Windows and Linux, which have no Firebase SDK: + /// the same Google Analytics property, reached over the Measurement + /// Protocol. Null leaves those platforms without analytics. + final MeasurementProtocolConfig? measurementProtocol, /// Linked wherever the app asks for consent. required final String privacyPolicyUrl, }); + +/// A Google Analytics 4 web data stream that takes events over the +/// Measurement Protocol. +@immutable +class const MeasurementProtocolConfig({ + /// The stream's measurement ID, `G-…`. + required final String measurementId, + + /// A Measurement Protocol API secret created on that stream (Admin ▸ Data + /// streams ▸ the stream ▸ Measurement Protocol API secrets). + required final String apiSecret, +}); diff --git a/test/core/telemetry/measurement_protocol_test.dart b/test/core/telemetry/measurement_protocol_test.dart new file mode 100644 index 0000000..0f571fe --- /dev/null +++ b/test/core/telemetry/measurement_protocol_test.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pinbench_edition_api/telemetry.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:pinbench/core/telemetry/analytics_service.dart'; +import 'package:pinbench/core/telemetry/measurement_protocol.dart'; + +const _config = MeasurementProtocolConfig(measurementId: 'G-TEST', apiSecret: 'secret'); + +/// Every request the backend made, decoded. +class _Requests { + final uris = []; + final bodies = >[]; + + late final client = MockClient((request) async { + uris.add(request.url); + bodies.add(jsonDecode(request.body) as Map); + return http.Response('', 204); + }); + + List> get events => [ + for (final body in bodies) ...(body['events']! as List).cast>(), + ]; +} + +void main() { + late _Requests requests; + late DateTime now; + + MeasurementProtocolBackend backend({bool validateOnly = false}) => MeasurementProtocolBackend( + config: _config, + clientId: '42.1700000000', + platform: 'windows', + language: 'en-US', + client: requests.client, + clock: () => now, + validateOnly: validateOnly, + ); + + setUp(() { + requests = _Requests(); + now = DateTime.utc(2026, 10, 22, 12); + }); + + group('backend', () { + test('sends a batch with the client, device and user properties', () async { + final mp = backend(); + await mp.setUserProperty('app_version', '0.1.2'); + await mp.logEvent('open_template', {'template': 'blink'}); + await mp.flush(); + + expect(requests.uris.single.path, '/mp/collect'); + expect(requests.uris.single.queryParameters, { + 'measurement_id': 'G-TEST', + 'api_secret': 'secret', + }); + final body = requests.bodies.single; + expect(body['client_id'], '42.1700000000'); + expect(body['user_properties'], { + 'app_version': {'value': '0.1.2'}, + }); + expect(body['device'], { + 'category': 'desktop', + 'operating_system': 'Windows', + 'language': 'en-US', + }); + expect(requests.events.single['name'], 'open_template'); + expect((requests.events.single['params']! as Map)['template'], 'blink'); + }); + + test('debug builds only validate', () async { + final mp = backend(validateOnly: true); + await mp.logEvent('x', null); + await mp.flush(); + + expect(requests.uris.single.path, '/debug/mp/collect'); + }); + + test('screens go as page_view, which a web stream accepts', () async { + final mp = backend(); + await mp.logScreenView('canvas'); + await mp.flush(); + + final event = requests.events.single; + expect(event['name'], 'page_view'); + expect((event['params']! as Map)['page_title'], 'canvas'); + }); + + test('keeps a session going, and starts a new one after 30 idle minutes', () async { + final mp = backend(); + await mp.logEvent('a', null); + now = now.add(const Duration(seconds: 20)); + await mp.logEvent('b', null); + now = now.add(const Duration(minutes: 31)); + await mp.logEvent('c', null); + await mp.flush(); + + final params = [for (final e in requests.events) e['params']! as Map]; + expect(params[0]['session_id'], params[1]['session_id']); + expect(params[2]['session_id'], isNot(params[1]['session_id'])); + // Every event claims some engagement, or GA4 does not count the user as + // active: the first of a session the least, the next the time between. + expect([for (final p in params) p['engagement_time_msec']], [1, 20000, 1]); + }); + + test('a long gap claims at most a minute of engagement', () async { + final mp = backend(); + await mp.logEvent('a', null); + now = now.add(const Duration(minutes: 20)); + await mp.logEvent('b', null); + await mp.flush(); + + expect((requests.events.last['params']! as Map)['engagement_time_msec'], 60000); + }); + + test('sends at most 25 events a request', () async { + final mp = backend(); + for (var i = 0; i < 30; i++) { + await mp.logEvent('e$i', null); + } + await mp.flush(); + + expect([for (final body in requests.bodies) (body['events']! as List).length], [25, 5]); + }); + + test('sends on its own after the flush delay', () async { + final mp = MeasurementProtocolBackend( + config: _config, + clientId: 'c', + platform: 'linux', + language: 'de-DE', + client: requests.client, + flushDelay: Duration.zero, + validateOnly: false, + ); + await mp.logEvent('e', null); + await pumpEventQueue(); + + expect(requests.events, hasLength(1)); + expect((requests.bodies.single['device']! as Map)['operating_system'], 'Linux'); + }); + + test('withdrawing consent drops what has not been sent', () async { + final mp = backend(); + await mp.logEvent('e', null); + await mp.setConsent(granted: false); + await mp.flush(); + + expect(requests.bodies, isEmpty); + }); + + test('a failed request is dropped, not thrown', () async { + final mp = MeasurementProtocolBackend( + config: _config, + clientId: 'c', + platform: 'windows', + language: 'en', + client: MockClient((_) => throw http.ClientException('offline')), + validateOnly: false, + ); + await mp.logEvent('e', null); + await expectLater(mp.flush(), completes); + expect(mp.pending, isEmpty); + }); + }); + + group('control', () { + late SharedPreferences prefs; + late List clientIds; + late List turnedOn; + late MeasurementProtocolBackend? created; + + MeasurementProtocolControl control() => MeasurementProtocolControl( + 'https://example.com/privacy', + prefs: prefs, + onAnalyticsOn: turnedOn.add, + createBackend: (clientId) { + clientIds.add(clientId); + return created = backend(); + }, + ); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + clientIds = []; + turnedOn = []; + created = null; + }); + + test('creates nothing — not even a client id — until the user agrees', () { + final mp = control()..applyConsent(granted: false); + + expect(mp.analytics.enabled, isFalse); + expect(clientIds, isEmpty); + expect(prefs.getString('analytics.client_id'), isNull); + }); + + test('a first agreement makes a client id and reports the first launch', () { + final mp = control()..applyConsent(granted: true); + + expect(mp.analytics.enabled, isTrue); + expect(turnedOn, [mp.analytics]); + expect(prefs.getString('analytics.client_id'), clientIds.single); + expect( + [for (final e in created!.pending) e['name']], + [MeasurementProtocolBackend.firstLaunchEvent], + ); + }); + + test('a later run keeps the client id and is no first launch', () { + control().applyConsent(granted: true); + final first = clientIds.single; + + control().applyConsent(granted: true); + + expect(clientIds, [first, first]); + expect(created!.pending, isEmpty); + }); + + test('withdrawing switches it off', () { + final mp = control() + ..applyConsent(granted: true) + ..applyConsent(granted: false); + + expect(mp.analytics.enabled, isFalse); + expect(created!.pending, isEmpty); + }); + }); +} From b129760de0ef15dcd735d3779b5a42020a0a4f00 Mon Sep 17 00:00:00 2001 From: burhankhanzada Date: Thu, 22 Oct 2026 22:17:10 +0200 Subject: [PATCH 2/2] Keep a dragged part's wires attached while it moves The drag edited the node and wire lists in place, and the wire painter only repaints when handed a different list, so the wires stayed behind until the drop. The drag now hands over fresh lists. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../canvas/managers/selection_manager.dart | 25 +++++++++++++------ .../canvas/selection_manager_test.dart | 16 ++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/lib/features/canvas/managers/selection_manager.dart b/lib/features/canvas/managers/selection_manager.dart index 6df0dbd..9546eef 100644 --- a/lib/features/canvas/managers/selection_manager.dart +++ b/lib/features/canvas/managers/selection_manager.dart @@ -411,17 +411,24 @@ class SelectionManager(final CanvasContext context) { _dragAccumulator -= actualTranslation; - for (var i = 0; i < context.selectedNodes.length; i++) { - final node = context.selectedNodes[i]; - final index = context.nodes.indexOf(node); + // Fresh lists, not edits in place: the wire painter repaints only when + // it's handed a different list, so mutating the old one left every + // wire on a dragged part frozen where it was until the drop. + final newNodes = List.from(context.nodes); + final newSelectedNodes = List.from(context.selectedNodes); + for (var i = 0; i < newSelectedNodes.length; i++) { + final node = newSelectedNodes[i]; + final index = newNodes.indexOf(node); if (index == -1) continue; final updatedNode = node.copyWith(position: node.position + actualTranslation); - context.nodes[index] = updatedNode; - context.selectedNodes[i] = updatedNode; + newNodes[index] = updatedNode; + newSelectedNodes[i] = updatedNode; } context.updateState( + nodes: newNodes, + selectedNodes: newSelectedNodes, verticalGuidelines: verticalGuidelines, horizontalGuidelines: horizontalGuidelines, ); @@ -432,16 +439,18 @@ class SelectionManager(final CanvasContext context) { } if (context.selectedWireIds.isNotEmpty && actualTranslation != Offset.zero) { + final newWires = List.from(context.wires); for (final wireId in context.selectedWireIds) { - final wireIndex = context.wires.indexWhere((w) => w.id == wireId); + final wireIndex = newWires.indexWhere((w) => w.id == wireId); if (wireIndex != -1) { - final wire = context.wires[wireIndex]; + final wire = newWires[wireIndex]; if (wire.bendPoints.isNotEmpty) { final newBendPoints = wire.bendPoints.map((p) => p + actualTranslation).toList(); - context.wires[wireIndex] = wire.copyWith(bendPoints: newBendPoints); + newWires[wireIndex] = wire.copyWith(bendPoints: newBendPoints); } } } + context.updateState(wires: newWires); } context.forceUpdate(); diff --git a/test/features/canvas/selection_manager_test.dart b/test/features/canvas/selection_manager_test.dart index 28e15d2..87830b3 100644 --- a/test/features/canvas/selection_manager_test.dart +++ b/test/features/canvas/selection_manager_test.dart @@ -100,6 +100,22 @@ void main() { expect(moved.position.dx % cellSize, closeTo(0, 0.001)); }); + test('a drag hands the painters a new node list, so wires follow live', () { + // The wire painter repaints only when its node list isn't the one it + // last drew. Editing that list in place left a dragged part's wires + // behind until the drop committed a fresh one. + final node = ComponentInstance(position: Offset.zero, part: _model(PartNames.resistor)); + controller.add(node); + controller.selectedNodes = [node]; + final before = controller.nodes; + + controller.selectionManager.moveSelection(Offset(controller.gridCellSize * 3, 0)); + + expect(identical(controller.nodes, before), isFalse); + expect(before.single.position, Offset.zero, reason: 'the old list is left as it was'); + expect(controller.nodes.single.position, isNot(Offset.zero)); + }); + test('snapping aligns a dragged node to a nearby node on the same axis', () { final anchor = ComponentInstance( position: const Offset(200, 0),