Skip to content

Commit 786e4a1

Browse files
authored
Merge pull request #109 from flutter-news-app-full-source-code/feat/analytics-reporting-engine
Feat/analytics reporting engine
2 parents e609a99 + f8a1218 commit 786e4a1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+4595
-140
lines changed

.env.example

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,28 @@ FIREBASE_PRIVATE_KEY="your-firebase-private-key"
6060
ONESIGNAL_APP_ID="your-onesignal-app-id"
6161
ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key"
6262

63+
# -----------------------------------------------------------------------------
64+
# SECTION 4: ANALYTICS PROVIDERS (CONDITIONALLY REQUIRED)
65+
# Provide credentials for analytics providers to enable dashboard metrics.
66+
# The server will start without these, but the analytics sync worker will skip them.
67+
# -----------------------------------------------------------------------------
68+
69+
# --- Google Analytics (via Firebase) ---
70+
# The ID of your Google Analytics 4 property.
71+
# Note: This requires the Firebase credentials from Section 3 to be configured
72+
# as it uses the same authentication mechanism.
73+
GOOGLE_ANALYTICS_PROPERTY_ID="your-ga4-property-id"
74+
75+
# --- Mixpanel ---
76+
# The Project ID for your Mixpanel project.
77+
MIXPANEL_PROJECT_ID="your-mixpanel-project-id"
78+
# The username for your Mixpanel service account.
79+
MIXPANEL_SERVICE_ACCOUNT_USERNAME="your-mixpanel-service-account-username"
80+
# The secret for your Mixpanel service account.
81+
MIXPANEL_SERVICE_ACCOUNT_SECRET="your-mixpanel-service-account-secret"
6382

6483
# -----------------------------------------------------------------------------
65-
# SECTION 4: API SECURITY & RATE LIMITING (OPTIONAL)
84+
# SECTION 5: API SECURITY & RATE LIMITING (OPTIONAL)
6685
# Fine-tune security settings. Defaults are provided if these are not set.
6786
# -----------------------------------------------------------------------------
6887

@@ -80,7 +99,7 @@ ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key"
8099

81100

82101
# -----------------------------------------------------------------------------
83-
# SECTION 5: ADVANCED & MISCELLANEOUS CONFIGURATION (OPTIONAL)
102+
# SECTION 6: ADVANCED & MISCELLANEOUS CONFIGURATION (OPTIONAL)
84103
# -----------------------------------------------------------------------------
85104

86105
# The duration for which a JWT is valid, in hours. Defaults to 720 (30 days).

.github/workflows/main.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ jobs:
1616
build:
1717
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1
1818
with:
19-
min_coverage: 0
19+
min_coverage: 40

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
</div>
66

77
<p align="center">
8-
<img src="https://img.shields.io/badge/coverage-_%25-red?style=for-the-badge" alt="coverage">
8+
<img src="https://img.shields.io/badge/coverage-53%25-green?style=for-the-badge" alt="coverage">
99
<a href="https://flutter-news-app-full-source-code.github.io/docs/api-server/local-setup/"><img src="https://img.shields.io/badge/DOCUMENTATION-READ-slategray?style=for-the-badge" alt="Documentation: Read"></a>
1010
</p>
1111
<p align="center">
@@ -107,11 +107,43 @@ A complete, multi-provider notification engine empowers you to engage users with
107107
108108
</details>
109109

110+
<details>
111+
<summary><strong>📊 Insightful Analytics Engine</strong></summary>
112+
113+
### 📈 A Unified Business Intelligence Engine
114+
A complete, multi-provider analytics engine that transforms raw data from both external services and your own application database into insightful, aggregated metrics for your dashboard.
115+
- **Dual-Source ETL:** A standalone worker process runs on a schedule to perform a full Extract, Transform, and Load (ETL) operation. It pulls behavioral data from your chosen analytics provider (Google Analytics or Mixpanel) and combines it with operational data by running direct, complex aggregations against the application's own database.
116+
- **High-Performance Dashboard:** The web dashboard reads this pre-aggregated data, resulting in near-instant load times for all analytics charts and metrics. This architecture avoids slow, direct, on-the-fly queries from the client to the analytics provider.
117+
- **Provider-Agnostic Design:** The engine is built on a provider-agnostic interface. You can switch between Google Analytics and Mixpanel via a simple configuration change, without altering any code.
118+
- **Extensible & Scalable:** Adding new charts or KPIs is as simple as defining a new mapping. The system is designed to be easily extended to track new metrics as your application evolves.
119+
> **Your Advantage:** Get a complete, production-grade BI pipeline out of the box. Deliver a fast, responsive dashboard and gain a holistic view of your business by combining user behavior analytics with real-time operational metrics—a capability that external analytics tools alone cannot provide.
120+
121+
</details>
122+
110123
<details>
111124
<summary><strong>🏗️ Architecture & Infrastructure</strong></summary>
112125

113126
### 🚀 High-Performance by Design
114-
Built on a modern, minimalist foundation to ensure low latency and excellent performance.
127+
- **Dart Frog Core:** Leverages the high-performance Dart Frog framework for a fast, efficient, and scalable backend.
128+
- **Clean, Layered Architecture:** A strict separation of concerns into distinct layers makes the codebase clean, maintainable, and easy to reason about.
129+
> **Your Advantage:** Your backend is built on a solid, modern foundation that is both powerful and a pleasure to work with, reducing maintenance overhead.
130+
131+
---
132+
133+
### 🔌 Extensible & Unlocked
134+
The entire application is designed with a robust dependency injection system, giving you the freedom to choose your own infrastructure.
135+
- **Swappable Implementations:** Easily swap out core components—like the database, email provider, or file storage service—without rewriting business logic.
136+
> **Your Advantage:** Avoid vendor lock-in and future-proof your application. You have the freedom to adapt and evolve your tech stack as your business needs change.
137+
138+
---
139+
140+
### 🔄 Automated & Traceable Database Migrations
141+
Say goodbye to risky manual database updates. A professional, versioned migration system ensures your database schema evolves safely and automatically.
142+
- **Code-Driven Schema Evolution:** The system automatically applies schema changes to your database on application startup, ensuring consistency across all environments.
143+
- **Traceable to Source:** Each migration is versioned and directly linked to the pull request that initiated it, providing a clear, auditable history of every change.
144+
> **Your Advantage:** Deploy with confidence. This robust system eliminates an entire class of deployment errors, ensuring your data models evolve gracefully and reliably with full traceability.
145+
146+
</details>
115147

116148
## 🔑 Licensing
117149
This `Flutter News App API Server` package is an integral part of the [**Flutter News App Full Source Code Toolkit**](https://github.com/flutter-news-app-full-source-code). For comprehensive details regarding licensing, including trial and commercial options for the entire toolkit, please refer to the main toolkit organization page.

analysis_options.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ analyzer:
1212
one_member_abstracts: ignore
1313
cascade_invocations: ignore
1414
cast_nullable_to_non_nullable: ignore
15+
specify_nonobvious_property_types: ignore
16+
unnecessary_null_checks: ignore
1517
exclude:
1618
- build/**
1719
linter:

bin/analytics_sync_worker.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'dart:io';
2+
3+
import 'package:flutter_news_app_api_server_full_source_code/src/config/app_dependencies.dart';
4+
import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
5+
import 'package:logging/logging.dart';
6+
7+
/// The main entry point for the standalone Analytics Sync Worker process.
8+
///
9+
/// This script initializes application dependencies, retrieves the
10+
/// [AnalyticsSyncService], and executes its `run()` method to perform the
11+
/// periodic data synchronization.
12+
///
13+
/// This executable can be compiled into a native binary and run by a scheduler
14+
/// (e.g., a cron job) to automate the analytics data pipeline.
15+
Future<void> main(List<String> args) async {
16+
// Configure logger for console output.
17+
Logger.root.level = Level.ALL;
18+
Logger.root.onRecord.listen((record) {
19+
// ignore: avoid_print
20+
print('${record.level.name}: ${record.time}: ${record.message}');
21+
if (record.error != null) {
22+
// ignore: avoid_print
23+
print(' ERROR: ${record.error}');
24+
}
25+
if (record.stackTrace != null) {
26+
// ignore: avoid_print
27+
print(' STACK TRACE: ${record.stackTrace}');
28+
}
29+
});
30+
31+
await AppDependencies.instance.init();
32+
await AppDependencies.instance.analyticsSyncService.run();
33+
await AppDependencies.instance.dispose();
34+
exit(0);
35+
}

lib/src/config/app_dependencies.dart

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import 'package:email_sendgrid/email_sendgrid.dart';
1010
import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart';
1111
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/all_migrations.dart';
1212
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart';
13+
import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
1314
import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart';
1415
import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart';
1516
import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart';
16-
import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart';
1717
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart';
1818
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart';
1919
import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_action_limit_service.dart';
@@ -72,19 +72,22 @@ class AppDependencies {
7272
pushNotificationDeviceRepository;
7373
late final DataRepository<RemoteConfig> remoteConfigRepository;
7474
late final DataRepository<InAppNotification> inAppNotificationRepository;
75+
late final DataRepository<KpiCardData> kpiCardDataRepository;
76+
late final DataRepository<ChartCardData> chartCardDataRepository;
77+
late final DataRepository<RankedListCardData> rankedListCardDataRepository;
7578

7679
late final DataRepository<Engagement> engagementRepository;
7780
late final DataRepository<Report> reportRepository;
7881
late final DataRepository<AppReview> appReviewRepository;
7982
late final EmailRepository emailRepository;
8083

8184
// Services
85+
late final AnalyticsSyncService analyticsSyncService;
8286
late final DatabaseMigrationService databaseMigrationService;
8387
late final TokenBlacklistService tokenBlacklistService;
8488
late final AuthTokenService authTokenService;
8589
late final VerificationCodeStorageService verificationCodeStorageService;
8690
late final AuthService authService;
87-
late final DashboardSummaryService dashboardSummaryService;
8891
late final PermissionService permissionService;
8992
late final UserActionLimitService userActionLimitService;
9093
late final RateLimitService rateLimitService;
@@ -232,6 +235,24 @@ class AppDependencies {
232235
logger: Logger('DataMongodb<InAppNotification>'),
233236
);
234237

238+
final kpiCardDataClient = DataMongodb<KpiCardData>(
239+
connectionManager: _mongoDbConnectionManager,
240+
modelName: 'kpi_card_data',
241+
fromJson: KpiCardData.fromJson,
242+
toJson: (item) => item.toJson(),
243+
);
244+
final chartCardDataClient = DataMongodb<ChartCardData>(
245+
connectionManager: _mongoDbConnectionManager,
246+
modelName: 'chart_card_data',
247+
fromJson: ChartCardData.fromJson,
248+
toJson: (item) => item.toJson(),
249+
);
250+
final rankedListCardDataClient = DataMongodb<RankedListCardData>(
251+
connectionManager: _mongoDbConnectionManager,
252+
modelName: 'ranked_list_card_data',
253+
fromJson: RankedListCardData.fromJson,
254+
toJson: (item) => item.toJson(),
255+
);
235256
_log.info('Initialized data client for InAppNotification.');
236257

237258
final engagementClient = DataMongodb<Engagement>(
@@ -349,6 +370,13 @@ class AppDependencies {
349370
engagementRepository = DataRepository(dataClient: engagementClient);
350371
reportRepository = DataRepository(dataClient: reportClient);
351372
appReviewRepository = DataRepository(dataClient: appReviewClient);
373+
kpiCardDataRepository = DataRepository(dataClient: kpiCardDataClient);
374+
chartCardDataRepository = DataRepository(
375+
dataClient: chartCardDataClient,
376+
);
377+
rankedListCardDataRepository = DataRepository(
378+
dataClient: rankedListCardDataClient,
379+
);
352380

353381
// Configure the HTTP client for SendGrid.
354382
// The HttpClient's AuthInterceptor will use the tokenProvider to add
@@ -394,11 +422,6 @@ class AppDependencies {
394422
userContentPreferencesRepository: userContentPreferencesRepository,
395423
log: Logger('AuthService'),
396424
);
397-
dashboardSummaryService = DashboardSummaryService(
398-
headlineRepository: headlineRepository,
399-
topicRepository: topicRepository,
400-
sourceRepository: sourceRepository,
401-
);
402425
userActionLimitService = DefaultUserActionLimitService(
403426
remoteConfigRepository: remoteConfigRepository,
404427
engagementRepository: engagementRepository,
@@ -424,6 +447,69 @@ class AppDependencies {
424447
log: Logger('DefaultPushNotificationService'),
425448
);
426449

450+
// --- Analytics Services ---
451+
final gaPropertyId = EnvironmentConfig.googleAnalyticsPropertyId;
452+
final mpProjectId = EnvironmentConfig.mixpanelProjectId;
453+
final mpUser = EnvironmentConfig.mixpanelServiceAccountUsername;
454+
final mpSecret = EnvironmentConfig.mixpanelServiceAccountSecret;
455+
456+
GoogleAnalyticsDataClient? googleAnalyticsClient;
457+
if (gaPropertyId != null && firebaseAuthenticator != null) {
458+
final googleAnalyticsHttpClient = HttpClient(
459+
baseUrl: 'https://analyticsdata.googleapis.com/v1beta',
460+
tokenProvider: firebaseAuthenticator!.getAccessToken,
461+
logger: Logger('GoogleAnalyticsHttpClient'),
462+
);
463+
464+
googleAnalyticsClient = GoogleAnalyticsDataClient(
465+
headlineRepository: headlineRepository,
466+
propertyId: gaPropertyId,
467+
firebaseAuthenticator: firebaseAuthenticator!,
468+
log: Logger('GoogleAnalyticsDataClient'),
469+
httpClient: googleAnalyticsHttpClient,
470+
);
471+
} else {
472+
_log.warning(
473+
'Google Analytics client could not be initialized due to missing '
474+
'property ID or Firebase authenticator.',
475+
);
476+
}
477+
478+
MixpanelDataClient? mixpanelClient;
479+
if (mpProjectId != null && mpUser != null && mpSecret != null) {
480+
mixpanelClient = MixpanelDataClient(
481+
headlineRepository: headlineRepository,
482+
projectId: mpProjectId,
483+
serviceAccountUsername: mpUser,
484+
serviceAccountSecret: mpSecret,
485+
log: Logger('MixpanelDataClient'),
486+
);
487+
} else {
488+
_log.warning(
489+
'Mixpanel client could not be initialized due to missing credentials.',
490+
);
491+
}
492+
493+
final analyticsMetricMapper = AnalyticsMetricMapper();
494+
495+
analyticsSyncService = AnalyticsSyncService(
496+
remoteConfigRepository: remoteConfigRepository,
497+
kpiCardRepository: kpiCardDataRepository,
498+
chartCardRepository: chartCardDataRepository,
499+
rankedListCardRepository: rankedListCardDataRepository,
500+
userRepository: userRepository,
501+
topicRepository: topicRepository,
502+
sourceRepository: sourceRepository,
503+
reportRepository: reportRepository,
504+
headlineRepository: headlineRepository,
505+
googleAnalyticsClient: googleAnalyticsClient,
506+
mixpanelClient: mixpanelClient,
507+
analyticsMetricMapper: analyticsMetricMapper,
508+
engagementRepository: engagementRepository,
509+
appReviewRepository: appReviewRepository,
510+
log: Logger('AnalyticsSyncService'),
511+
);
512+
427513
_log.info('Application dependencies initialized successfully.');
428514
// Signal that initialization has completed successfully.
429515
_initCompleter!.complete();
@@ -459,7 +545,7 @@ class AppDependencies {
459545
await _mongoDbConnectionManager.close();
460546
tokenBlacklistService.dispose();
461547
rateLimitService.dispose();
462-
countryQueryService.dispose(); // Dispose the new service
548+
countryQueryService.dispose();
463549

464550
// Reset the completer to allow for re-initialization (e.g., in tests).
465551
_initCompleter = null;

lib/src/config/environment_config.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,19 @@ abstract final class EnvironmentConfig {
219219
///
220220
/// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable, if available.
221221
static String? get oneSignalRestApiKey => _getEnv('ONESIGNAL_REST_API_KEY');
222+
223+
/// Retrieves the Google Analytics Property ID from the environment.
224+
static String? get googleAnalyticsPropertyId =>
225+
_getEnv('GOOGLE_ANALYTICS_PROPERTY_ID');
226+
227+
/// Retrieves the Mixpanel Project ID from the environment.
228+
static String? get mixpanelProjectId => _getEnv('MIXPANEL_PROJECT_ID');
229+
230+
/// Retrieves the Mixpanel Service Account Username from the environment.
231+
static String? get mixpanelServiceAccountUsername =>
232+
_getEnv('MIXPANEL_SERVICE_ACCOUNT_USERNAME');
233+
234+
/// Retrieves the Mixpanel Service Account Secret from the environment.
235+
static String? get mixpanelServiceAccountSecret =>
236+
_getEnv('MIXPANEL_SERVICE_ACCOUNT_SECRET');
222237
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export 'analytics_query.dart';
2+
export 'google_analytics_request.dart';
3+
export 'google_analytics_response.dart';
4+
export 'mixpanel_request.dart';
5+
export 'mixpanel_response.dart';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'package:core/core.dart';
2+
3+
/// A sealed class representing a structured, provider-agnostic analytics query.
4+
///
5+
/// This replaces the fragile pattern of passing primitive strings for metrics
6+
/// and dimensions, centralizing query definitions into type-safe objects.
7+
sealed class AnalyticsQuery {
8+
/// {@macro analytics_query}
9+
const AnalyticsQuery();
10+
}
11+
12+
/// A sealed class for queries that return a numeric metric value, such as
13+
/// a total count or a time series.
14+
sealed class MetricQuery extends AnalyticsQuery {
15+
/// {@macro metric_query}
16+
const MetricQuery();
17+
}
18+
19+
/// A query for a simple event count.
20+
///
21+
/// This is used when the metric is the count of a specific [AnalyticsEvent].
22+
class EventCountQuery extends MetricQuery {
23+
/// {@macro event_count_query}
24+
const EventCountQuery({required this.event});
25+
26+
/// The core, type-safe event from the shared [AnalyticsEvent] enum.
27+
final AnalyticsEvent event;
28+
}
29+
30+
/// A query for a standard, provider-defined metric (e.g., 'activeUsers').
31+
///
32+
/// This is used for metrics that have a built-in name in the provider's API.
33+
class StandardMetricQuery extends MetricQuery {
34+
/// {@macro standard_metric_query}
35+
const StandardMetricQuery({required this.metric});
36+
37+
/// The provider-specific name for a standard metric.
38+
final String metric;
39+
}
40+
41+
/// A query for a ranked list of items.
42+
///
43+
/// This is used to get a "Top N" list, such as most viewed headlines.
44+
class RankedListQuery extends AnalyticsQuery {
45+
/// {@macro ranked_list_query}
46+
const RankedListQuery({
47+
required this.event,
48+
required this.dimension,
49+
this.limit = 10,
50+
});
51+
52+
/// The event to count for ranking (e.g., `contentViewed`).
53+
final AnalyticsEvent event;
54+
55+
/// The property/dimension to group by (e.g., `contentId`).
56+
final String dimension;
57+
58+
/// The number of items to return in the ranked list.
59+
final int limit;
60+
}

0 commit comments

Comments
 (0)