Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions lib/app/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,17 @@ void installCloudLogSink() {
Future<FirebaseTelemetry> 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`).
Expand All @@ -133,8 +136,9 @@ Future<FirebaseTelemetry> 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);
}
Expand Down
1 change: 1 addition & 0 deletions lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ class _TemplateViewState extends ConsumerState<_TemplateView> {
/// spinner — keeps working even after the original caller is gone.
Future<void> 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();
Expand Down
46 changes: 46 additions & 0 deletions lib/core/telemetry/analytics_backend.dart
Original file line number Diff line number Diff line change
@@ -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<void> logEvent(String name, Map<String, Object>? params);

Future<void> logScreenView(String screenName);

Future<void> setUserProperty(String name, String value);

/// Consent mode. Ad storage is always denied: the app shows no ads.
Future<void> setConsent({required bool granted});

/// Sends anything buffered. Called before the app exits.
Future<void> flush();
}

/// [AnalyticsBackend] over the Firebase Analytics SDK.
class const FirebaseAnalyticsBackend(final FirebaseAnalytics _analytics)
implements AnalyticsBackend {
@override
Future<void> logEvent(String name, Map<String, Object>? params) =>
_analytics.logEvent(name: name, parameters: params);

@override
Future<void> logScreenView(String screenName) => _analytics.logScreenView(screenName: screenName);

@override
Future<void> setUserProperty(String name, String value) =>
_analytics.setUserProperty(name: name, value: value);

@override
Future<void> 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<void> flush() async {}
}
52 changes: 31 additions & 21 deletions lib/core/telemetry/analytics_service.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -42,15 +42,15 @@ class AnalyticsService {
void logEvent(String name, [Map<String, Object>? 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.
/// `editor`, `canvas`, `welcome`) — never a file path (see [tabSwitched]).
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
Expand All @@ -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.).
Expand All @@ -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<void> flush() async {
final analytics = _analytics;
if (analytics == null) return;
try {
await analytics.flush();
} catch (e) {
talker.warning('Analytics flush failed: $e');
}
}

// ── Segmentation user properties ────────────────────────────────────────────
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
62 changes: 57 additions & 5 deletions lib/core/telemetry/firebase_bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<FirebaseTelemetry> 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;
Expand All @@ -80,6 +100,35 @@ Future<FirebaseTelemetry> 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,
Expand Down Expand Up @@ -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;
}
}
Expand Down
Loading
Loading