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
7 changes: 4 additions & 3 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ OpenPanel):
comment posted (on an issue or a merge request, never its text), pipeline
retried or cancelled, job retried, cancelled, or run manually, to-do
cleared, and background checks turned on or off.
- Screen-view events carry only a **sanitized route** — numeric identifiers
are removed (`/projects/:id`), so no project, issue, or account can be
identified.
- Screen-view events carry only a **matched route template**. Every path
parameter, including project IDs and branch, tag, or environment names, is
replaced by a placeholder (for example, `/projects/:id`). Unmatched paths
are reported as `/:unknown`.
- Events contain **no personal data**: no usernames, names, email addresses,
tokens, titles, content, or full URLs.
- Analytics are never sold, shared with third parties, or used for
Expand Down
7 changes: 4 additions & 3 deletions apps/labfox/assets/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ OpenPanel):
comment posted (on an issue or a merge request, never its text), pipeline
retried or cancelled, job retried, cancelled, or run manually, to-do
cleared, and background checks turned on or off.
- Screen-view events carry only a **sanitized route** — numeric identifiers
are removed (`/projects/:id`), so no project, issue, or account can be
identified.
- Screen-view events carry only a **matched route template**. Every path
parameter, including project IDs and branch, tag, or environment names, is
replaced by a placeholder (for example, `/projects/:id`). Unmatched paths
are reported as `/:unknown`.
- Events contain **no personal data**: no usernames, names, email addresses,
tokens, titles, content, or full URLs.
- Analytics are never sold, shared with third parties, or used for
Expand Down
10 changes: 7 additions & 3 deletions apps/labfox/lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -795,10 +795,14 @@ final routerProvider = Provider<GoRouter>((ref) {
],
);

// One screen_view per navigation, with numeric ids collapsed so the route
// never identifies a project or item.
// Use go_router's matched path template so no dynamic path value reaches
// screen-view analytics, including names and tags in detail routes.
router.routerDelegate.addListener(() {
routeTracker.visit(router.routerDelegate.currentConfiguration.uri.path);
final configuration = router.routerDelegate.currentConfiguration;
routeTracker.visit(
configuration.uri.toString(),
routeTemplate: configuration.fullPath,
);
});
return router;
});
63 changes: 44 additions & 19 deletions apps/labfox/lib/core/analytics/analytics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,42 @@ class Analytics {
}
}

/// Collapses numeric path segments (`/projects/42` → `/projects/:id`) so a
/// screen-view route never carries an identifying or high-cardinality value.
/// Commit route parameters are hidden even when an invalid deep link is visited.
String sanitizeRoute(String path) {
final segments = path.split('/');
return [
for (var i = 0; i < segments.length; i++)
if (i > 0 &&
(segments[i - 1] == 'commit' || segments[i - 1] == 'commits'))
':sha'
else if (int.tryParse(segments[i]) != null)
':id'
else
segments[i],
].join('/');
/// Reports only the matched route template, never values from the location.
/// An absent or mismatched template fails closed, including invalid deep links.
String sanitizeRoute(String location, {String? routeTemplate}) {
const unknownRoute = '/:unknown';
if (routeTemplate == null || !routeTemplate.startsWith('/')) {
return unknownRoute;
}

final uri = Uri.tryParse(location);
if (uri == null ||
uri.hasScheme ||
uri.hasAuthority ||
!location.startsWith('/')) {
return unknownRoute;
}
if (routeTemplate == '/') {
return uri.path == '/' ? '/' : unknownRoute;
}

final templateSegments = routeTemplate.substring(1).split('/');
final actualSegments = uri.pathSegments;
if (templateSegments.length != actualSegments.length) {
return unknownRoute;
}
final parameter = RegExp(r'^:[A-Za-z][A-Za-z0-9_]*$');
final literal = RegExp(r'^[a-z][a-z0-9_-]*$');
for (var i = 0; i < templateSegments.length; i++) {
final segment = templateSegments[i];
if (parameter.hasMatch(segment)) {
continue;
}
if (!literal.hasMatch(segment) || segment != actualSegments[i]) {
return unknownRoute;
}
}
return routeTemplate;
}

final analyticsProvider = Provider<Analytics>((ref) {
Expand All @@ -103,12 +124,16 @@ class RouteTracker {
final Analytics _analytics;
String? _lastPath;

void visit(String path) {
if (path == _lastPath) {
void visit(String location, {String? routeTemplate}) {
if (location == _lastPath) {
return;
}
_lastPath = path;
unawaited(_analytics.track('screen_view', {'route': sanitizeRoute(path)}));
_lastPath = location;
unawaited(
_analytics.track('screen_view', {
'route': sanitizeRoute(location, routeTemplate: routeTemplate),
}),
);
}
}

Expand Down
20 changes: 18 additions & 2 deletions apps/labfox/test/analytics_app_open_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import 'package:labfox/core/auth/auth_state.dart';
/// Records event names instead of posting anywhere.
class _RecordingAnalytics implements Analytics {
final List<String> events = [];
final List<String?> routes = [];

@override
Future<void> track(String name, [Map<String, Object?>? properties]) async {
events.add(name);
if (name == 'screen_view') {
routes.add(properties?['route'] as String?);
}
}
}

Expand Down Expand Up @@ -91,7 +95,7 @@ void main() {
container.read(routerProvider);

final tracker = container.read(routeTrackerProvider);
tracker.visit('/inbox');
tracker.visit('/inbox', routeTemplate: '/inbox');

// An auth change rebuilds the router. The tracker must not be rebuilt with
// it, or the screen the user is already on is counted a second time.
Expand All @@ -102,8 +106,20 @@ void main() {
final afterRebuild = container.read(routeTrackerProvider);
expect(identical(tracker, afterRebuild), isTrue);

afterRebuild.visit('/inbox');
afterRebuild.visit('/inbox', routeTemplate: '/inbox');

expect(analytics.events.where((e) => e == 'screen_view'), hasLength(1));
});

test('screen views send a template without private route values', () {
final analytics = _RecordingAnalytics();
final tracker = RouteTracker(analytics);

tracker.visit(
Routes.protectedBranch(42, 'customer-secret'),
routeTemplate: '/projects/:id/protected_branches/:name',
);

expect(analytics.routes, ['/projects/:id/protected_branches/:name']);
});
}
92 changes: 87 additions & 5 deletions apps/labfox/test/analytics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:labfox/app/router.dart';
import 'package:labfox/core/analytics/analytics.dart';

class _Adapter implements HttpClientAdapter {
Expand Down Expand Up @@ -62,12 +63,93 @@ void main() {
await expectLater(analytics.track('app_open'), completes);
});

test('numeric path segments collapse so routes stay anonymous', () {
test('matched route templates keep numeric and static paths anonymous', () {
expect(
sanitizeRoute('/projects/42/merge_requests/7'),
'/projects/:id/merge_requests/:id',
sanitizeRoute(
'/projects/42/merge_requests/7',
routeTemplate: '/projects/:id/merge_requests/:iid',
),
'/projects/:id/merge_requests/:iid',
);
expect(
sanitizeRoute('/dashboard/issues', routeTemplate: '/dashboard/issues'),
'/dashboard/issues',
);
expect(sanitizeRoute('/', routeTemplate: '/'), '/');
});

test('private values in unmatched routes are never reported', () {
for (final path in [
'/projects/42/releases/customer-secret',
'/projects/42/container_registry/7/tags/customer-secret',
'/projects/42/protected_branches/customer-secret',
'/projects/42/protected_tags/customer-secret',
'/projects/42/protected_environments/customer-secret',
'/groups/42/protected_environments/customer-secret',
]) {
expect(sanitizeRoute(path), '/:unknown', reason: path);
}
});

test('matched detail routes hide private path values', () {
final cases = <(String, String)>[
(
Routes.release(42, 'customer-secret'),
'/projects/:id/releases/:tagName',
),
(
Routes.containerTag(42, 7, 'customer-secret'),
'/projects/:id/container_registry/:repositoryId/tags/:tagName',
),
(
Routes.protectedBranch(42, 'customer-secret'),
'/projects/:id/protected_branches/:name',
),
(
Routes.protectedTag(42, 'customer-secret'),
'/projects/:id/protected_tags/:name',
),
(
Routes.protectedEnvironment(42, 'customer-secret'),
'/projects/:id/protected_environments/:name',
),
(
Routes.groupProtectedEnvironment(42, 'customer-secret'),
'/groups/:id/protected_environments/:name',
),
(
Routes.protectedBranch(42, 'release/secret'),
'/projects/:id/protected_branches/:name',
),
(Routes.release(42, 'releases'), '/projects/:id/releases/:tagName'),
(Routes.wikiPage(42, 'customer-secret'), '/projects/:id/wikis/page'),
];

for (final (location, routeTemplate) in cases) {
final route = sanitizeRoute(location, routeTemplate: routeTemplate);
expect(route, routeTemplate, reason: location);
expect(route, isNot(contains('customer-secret')));
expect(route, isNot(contains('release/secret')));
}
});

test('mismatched or invalid templates fail closed', () {
const location = '/projects/42/releases/customer-secret';
expect(
sanitizeRoute(location, routeTemplate: '/projects/:id/releases'),
'/:unknown',
);
expect(
sanitizeRoute(
location,
routeTemplate: '/projects/:id/protected_branches/:name',
),
'/:unknown',
);
expect(sanitizeRoute(location, routeTemplate: ''), '/:unknown');
expect(
sanitizeRoute(location, routeTemplate: '/projects/42/releases/:tagName'),
'/:unknown',
);
expect(sanitizeRoute('/dashboard/issues'), '/dashboard/issues');
expect(sanitizeRoute('/'), '/');
});
}
5 changes: 4 additions & 1 deletion apps/labfox/test/container_cleanup_policy_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ void main() {
);
test('cleanup route analytics excludes the project and policy patterns', () {
expect(
sanitizeRoute(Routes.containerCleanupPolicy(7)),
sanitizeRoute(
Routes.containerCleanupPolicy(7),
routeTemplate: '/projects/:id/container_registry/cleanup_policy',
),
'/projects/:id/container_registry/cleanup_policy',
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,11 @@ void main() {

test('protection route analytics contains no project ID or rule pattern', () {
expect(
sanitizeRoute(Routes.containerRepositoryProtectionRules(7)),
sanitizeRoute(
Routes.containerRepositoryProtectionRules(7),
routeTemplate:
'/projects/:id/container_registry/protection/repositories',
),
'/projects/:id/container_registry/protection/repositories',
);
});
Expand Down
5 changes: 4 additions & 1 deletion apps/labfox/test/container_tag_protection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,10 @@ void main() {

test('protection route analytics contains no project ID or rule pattern', () {
expect(
sanitizeRoute(Routes.containerTagProtectionRules(7)),
sanitizeRoute(
Routes.containerTagProtectionRules(7),
routeTemplate: '/projects/:id/container_registry/protection/tags',
),
'/projects/:id/container_registry/protection/tags',
);
});
Expand Down
26 changes: 19 additions & 7 deletions apps/labfox/test/mr_original_review_ui_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,17 @@ void main() {
'commit routes remove canonical and invalid commit values from analytics',
() {
expect(
sanitizeRoute('/projects/8/merge_requests/142/commits/${o.head}'),
'/projects/:id/merge_requests/:id/commits/:sha',
sanitizeRoute(
'/projects/8/merge_requests/142/commits/${o.head}',
routeTemplate: '/projects/:id/merge_requests/:iid/commits/:sha',
),
'/projects/:id/merge_requests/:iid/commits/:sha',
);
expect(
sanitizeRoute('/projects/8/commit/private-value/changes'),
sanitizeRoute(
'/projects/8/commit/private-value/changes',
routeTemplate: '/projects/:id/commit/:sha/changes',
),
'/projects/:id/commit/:sha/changes',
);
},
Expand Down Expand Up @@ -266,12 +272,18 @@ void main() {
'/projects/8/merge_requests/142/commits/${o.head}',
);
expect(
sanitizeRoute('/projects/8/merge_requests/142/commits/${o.head}'),
'/projects/:id/merge_requests/:id/commits/:sha',
sanitizeRoute(
'/projects/8/merge_requests/142/commits/${o.head}',
routeTemplate: '/projects/:id/merge_requests/:iid/commits/:sha',
),
'/projects/:id/merge_requests/:iid/commits/:sha',
);
expect(
sanitizeRoute('/projects/8/merge_requests/142/commits/private-value'),
'/projects/:id/merge_requests/:id/commits/:sha',
sanitizeRoute(
'/projects/8/merge_requests/142/commits/private-value',
routeTemplate: '/projects/:id/merge_requests/:iid/commits/:sha',
),
'/projects/:id/merge_requests/:iid/commits/:sha',
);
},
);
Expand Down
Loading