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
1 change: 1 addition & 0 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ jobs:
CHANGE_MESSAGE: ${{ inputs.change_message }}
RELEASE_TITLE: ${{ inputs.release_title }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
run: |
$args = @(
"-ExecutionPolicy", "Bypass",
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release-store.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ jobs:

- name: Build Store Package
shell: pwsh
env:
POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
run: powershell -ExecutionPolicy Bypass -File scripts/build_store_release.ps1

- name: Upload Store Artifacts
Expand Down
49 changes: 49 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Anonymous analytics

Icarus sends a deliberately small set of anonymous product events to PostHog.
Analytics is enabled by default, can be disabled under **Settings > Privacy**, and
is inactive in builds that do not include a PostHog project token.

## Build configuration

Set `POSTHOG_PROJECT_TOKEN` before running `scripts/build_desktop_release.ps1`.
The script passes it to Flutter through a temporary Dart-defines file. For an
EU PostHog project, also set `POSTHOG_HOST=https://eu.i.posthog.com`; the default
is the US host.

The `release-desktop.yml` and `release-store.yml` GitHub Actions workflows read
the token from the `POSTHOG_PROJECT_TOKEN` repository secret. The build scripts
place it in a temporary Dart-defines file so it is not printed in Actions logs,
then delete the file immediately after the build.

The project token is a public ingestion token, not a private API key. Never use
a PostHog personal API key here.

For local development:

```powershell
fvm flutter run -d windows --dart-define=POSTHOG_PROJECT_TOKEN=phc_your_project_token
```

## Event schema

| Event | Properties | Purpose |
| --- | --- | --- |
| `app_opened` | Common properties only | Anonymous active-user and retention counts |
| `strategy_created` | Common properties only | Core activation |
| `strategy_imported` | Common properties only | Import adoption |
| `lineup_created` | `has_video`, `has_notes`, `has_images` | Lineup feature adoption without collecting content |
| `content_exported` | `content_type` (`strategy`, `folder`, or `library`) | Export and backup adoption |

Every event has a random installation ID, app version, build number, release
channel, and platform. Events set `$process_person_profile` to `false` and
`$geoip_disable` to `true`. Icarus does not collect names, file paths, strategy
content, URLs, notes, images, screen views, recordings, errors, or device details.
The PostHog project must also keep **Settings > Privacy > Discard client IP
data** enabled so the transport IP is removed after ingestion. This is enabled
for the production Icarus project.

Development smoke tests should set
`ICARUS_ANALYTICS_ENVIRONMENT=development`. Production builds default to
`analytics_environment=production`, making test events easy to exclude from
insights.
2 changes: 1 addition & 1 deletion lib/const/agents.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,7 @@ class AgentData implements DraggableData {

agent.abilities.first.abilityData = CircleAbility(
iconPath: agent.abilities.first.iconPath,
size: 24,
size: 30,
rangeOutlineColor: Colors.lightBlueAccent,
hasCenterDot: true,
);
Expand Down
2 changes: 1 addition & 1 deletion lib/const/folder_icons.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:icarus/const/custom_icons.dart';

const int folderIconRegistryVersion = 92;
const int folderIconRegistryVersion = 94;

enum FolderIconRenderKind {
material,
Expand Down
4 changes: 2 additions & 2 deletions lib/const/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ class Settings {
static final Uri dicordLink = Uri.parse("https://discord.gg/PN2uKwCqYB");

static const Duration autoSaveOffset = Duration(seconds: 15);
static const int versionNumber = 93;
static const String versionName = "4.5.0";
static const int versionNumber = 94;
static const String versionName = "4.5.1";
static final Uri desktopUpdaterArchiveUrl =
buildDesktopUpdaterArchiveUrl(kResolvedUpdateChannel);

Expand Down
2 changes: 1 addition & 1 deletion lib/hive/hive_adapters.g.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ types:
index: 4
AppPreferences:
typeId: 28
nextIndex: 16
nextIndex: 17
fields:
defaultThemeProfileIdForNewStrategies:
index: 0
Expand Down
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import 'package:icarus/providers/folder_provider.dart';
import 'package:icarus/providers/user_preferences_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/services/app_error_reporter.dart';
import 'package:icarus/services/analytics_service.dart';
import 'package:icarus/strategy_view.dart';
import 'package:icarus/widgets/folder_navigator.dart';
import 'package:icarus/widgets/global_shortcuts.dart';
Expand Down Expand Up @@ -80,11 +81,14 @@ Future<void> main(List<String> args) async {
await Hive.openBox<AppPreferences>(HiveBoxNames.appPreferencesBox);
await Hive.openBox<bool>(HiveBoxNames.favoriteAgentsBox);
await Hive.openBox<int>(HiveBoxNames.pinnedItemsBox);
await Hive.openBox<dynamic>(AnalyticsService.storageBoxName);

await MapThemeProfilesProvider.bootstrap();

await StrategyProvider.migrateAllStrategies();

await AnalyticsService.instance.initialize();

// await Hive.box<StrategyData>(HiveBoxNames.strategiesBox).clear();

await _initWebViewEnvironment();
Expand Down
22 changes: 22 additions & 0 deletions lib/providers/strategy_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import 'package:icarus/providers/strategy_page.dart';
import 'package:icarus/providers/strategy_settings_provider.dart';
import 'package:icarus/providers/text_provider.dart';
import 'package:icarus/services/app_error_reporter.dart';
import 'package:icarus/services/analytics_service.dart';
import 'package:hive_ce/hive.dart';
import 'package:icarus/const/drawing_element.dart';
import 'package:icarus/const/maps.dart';
Expand Down Expand Up @@ -3057,6 +3058,7 @@ class StrategyProvider extends Notifier<StrategyState> {

await Hive.box<StrategyData>(HiveBoxNames.strategiesBox)
.put(newStrategy.id, newStrategy);
unawaited(AnalyticsService.instance.capture('strategy_imported'));
} finally {
if (isZip) {
try {
Expand Down Expand Up @@ -3130,6 +3132,8 @@ class StrategyProvider extends Notifier<StrategyState> {
await Hive.box<StrategyData>(HiveBoxNames.strategiesBox)
.put(newStrategy.id, newStrategy);

unawaited(AnalyticsService.instance.capture('strategy_created'));

return newStrategy.id;
}

Expand Down Expand Up @@ -3179,6 +3183,12 @@ class StrategyProvider extends Notifier<StrategyState> {
encoder.create(outputFile);
await encoder.addDirectory(stagingDirectory, includeDirName: false);
await encoder.close();
unawaited(
AnalyticsService.instance.capture(
'content_exported',
properties: const {'content_type': 'folder'},
),
);
} finally {
try {
await stagingDirectory.delete(recursive: true);
Expand All @@ -3204,6 +3214,12 @@ class StrategyProvider extends Notifier<StrategyState> {
encoder.create(outputFile);
await encoder.addDirectory(stagingDirectory, includeDirName: false);
await encoder.close();
unawaited(
AnalyticsService.instance.capture(
'content_exported',
properties: const {'content_type': 'library'},
),
);
} finally {
try {
await stagingDirectory.delete(recursive: true);
Expand Down Expand Up @@ -3567,6 +3583,12 @@ class StrategyProvider extends Notifier<StrategyState> {

if (outputFile == null) return;
await zipStrategy(id: id, outputFilePath: outputFile);
unawaited(
AnalyticsService.instance.capture(
'content_exported',
properties: const {'content_type': 'strategy'},
),
);
}

Future<void> renameStrategy(String strategyID, String newName) async {
Expand Down
133 changes: 133 additions & 0 deletions lib/services/analytics_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:icarus/const/settings.dart';
import 'package:uuid/uuid.dart';

/// Minimal, anonymous product analytics.
///
/// This intentionally uses PostHog's capture endpoint directly so analytics
/// works on Windows, which the official Flutter SDK does not currently support.
/// It does not collect screen views, exceptions, session replays, user-provided
/// content, or person profiles.
class AnalyticsService {
AnalyticsService._({http.Client? client}) : _client = client ?? http.Client();

static final AnalyticsService instance = AnalyticsService._();

static const String _projectToken = String.fromEnvironment(
'POSTHOG_PROJECT_TOKEN',
);
static const String _host = String.fromEnvironment(
'POSTHOG_HOST',
defaultValue: 'https://us.i.posthog.com',
);
static const String _environment = String.fromEnvironment(
'ICARUS_ANALYTICS_ENVIRONMENT',
defaultValue: 'production',
);
static const String storageBoxName = 'anonymous_analytics';
static const String _anonymousIdKey = 'anonymous_id';

final http.Client _client;
static const String _enabledKey = 'enabled';

Box<dynamic>? _storage;
String? _anonymousId;
bool _enabled = true;

bool get isConfigured => _projectToken.trim().isNotEmpty;
bool get isEnabled => _enabled;

Future<void> initialize() async {
_storage = Hive.box<dynamic>(storageBoxName);
_enabled = _storage!.get(_enabledKey, defaultValue: true) as bool;
_anonymousId = _storage!.get(_anonymousIdKey);
if (_anonymousId == null) {
_anonymousId = const Uuid().v4();
await _storage!.put(_anonymousIdKey, _anonymousId!);
}

unawaited(capture('app_opened'));
}

Future<void> setEnabled(bool enabled) async {
_enabled = enabled;
await _storage?.put(_enabledKey, enabled);
if (enabled) unawaited(capture('app_opened'));
}

Future<void> capture(
String event, {
Map<String, Object>? properties,
}) async {
final anonymousId = _anonymousId;
if (!_enabled || !isConfigured || anonymousId == null) return;

final payload = <String, Object>{
'api_key': _projectToken,
'event': event,
'uuid': const Uuid().v4(),
'timestamp': DateTime.now().toUtc().toIso8601String(),
'properties': <String, Object>{
'distinct_id': anonymousId,
r'$process_person_profile': false,
r'$geoip_disable': true,
'app_version': Settings.versionName,
'build_number': Settings.versionNumber,
'release_channel': kResolvedUpdateChannel,
'analytics_environment': _environment,
'platform': _platformName,
...?properties,
},
};

try {
await _client
.post(
_captureUri,
headers: const {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 5));
} catch (_) {
// Analytics must never affect app behavior or surface errors to users.
}
}

Uri get _captureUri {
final normalizedHost = _host.trim().replaceFirst(RegExp(r'/+$'), '');
return Uri.parse('$normalizedHost/i/v0/e/');
}

String get _platformName {
if (kIsWeb) return 'web';
return switch (defaultTargetPlatform) {
TargetPlatform.android => 'android',
TargetPlatform.iOS => 'ios',
TargetPlatform.linux => 'linux',
TargetPlatform.macOS => 'macos',
TargetPlatform.windows => 'windows',
TargetPlatform.fuchsia => 'fuchsia',
};
}
}

final analyticsEnabledProvider =
NotifierProvider<AnalyticsEnabledNotifier, bool>(
AnalyticsEnabledNotifier.new,
);

class AnalyticsEnabledNotifier extends Notifier<bool> {
@override
bool build() => AnalyticsService.instance.isEnabled;

Future<void> setEnabled(bool enabled) async {
state = enabled;
await AnalyticsService.instance.setEnabled(enabled);
}
}
Loading
Loading