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
4 changes: 2 additions & 2 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ RESPOND_MAPBOX_DLKEY=

# Analytics Configuration
RESPOND_SENTRY_DSN=
RESPOND_APTABASE_APP_KEY=
RESPOND_APTABASE_URL=
RESPOND_COUNTLY_APP_KEY=
RESPOND_COUNTLY_URL=
107 changes: 107 additions & 0 deletions ANALYTICS_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Analytics Migration: Aptabase to Countly

## Overview
Successfully migrated the project from Aptabase analytics to Countly with minimal disruption to the codebase.

## What Was Changed

### 1. Dependencies
- ❌ **Removed**: `@aptabase/react-native`
- ✅ **Added**: `countly-sdk-react-native-bridge@25.4.0`

### 2. Analytics Service (`src/services/analytics.service.ts`)
- **Completely rewritten** to use Countly SDK
- **Maintained same interface** for components (same method names and signatures)
- **Enhanced error handling** with retry logic and graceful degradation
- **Added comprehensive session management** (start, end, extend)
- **Proper user properties support**
- **Environment-driven initialization** with fallback configuration

#### Key Features:
- ✅ Event tracking with properties
- ✅ User properties management
- ✅ Session lifecycle management
- ✅ Error handling with retry mechanism
- ✅ Service status monitoring
- ✅ Graceful degradation on failures

### 3. Hook Integration (`src/hooks/use-analytics.ts`)
- **Updated** to use new `analyticsService` instead of `aptabaseService`
- **Interface unchanged** - components continue working without modifications
- **Performance optimized** with `useCallback`

### 4. Environment Configuration
- **Updated** `env.js` to support Countly variables:
- `RESPOND_COUNTLY_APP_KEY` (replaces `RESPOND_APTABASE_APP_KEY`)
- `RESPOND_COUNTLY_URL` (replaces `RESPOND_APTABASE_URL`)
- **Updated** `.env.development` with new variable names

Comment on lines +34 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Align env var names with implementation

Docs list RESPOND_COUNTLY_* keys, but the service currently reads COUNTLY_*; reconcile both sides and document the final names.

-  - `RESPOND_COUNTLY_APP_KEY` (replaces `RESPOND_APTABASE_APP_KEY`)
-  - `RESPOND_COUNTLY_URL` (replaces `RESPOND_APTABASE_URL`)
+  - `RESPOND_COUNTLY_APP_KEY` (or `COUNTLY_APP_KEY` for backward compat)
+  - `RESPOND_COUNTLY_URL` (or `COUNTLY_URL` for backward compat)
@@
-RESPOND_COUNTLY_APP_KEY=your_countly_app_key_here
-RESPOND_COUNTLY_URL=https://your-countly-server.com
+RESPOND_COUNTLY_APP_KEY=your_countly_app_key_here
+RESPOND_COUNTLY_URL=https://your-countly-server.com
+# Optionally support legacy names if present:
+# COUNTLY_APP_KEY=your_countly_app_key_here
+# COUNTLY_URL=https://your-countly-server.com

Also applies to: 79-83

🤖 Prompt for AI Agents
In ANALYTICS_MIGRATION.md around lines 34–38 and 79–83 the docs reference
RESPOND_COUNTLY_* env vars but the service reads COUNTLY_*; update the
documentation and any example .env entries to use the actual runtime names
(COUNTLY_APP_KEY and COUNTLY_URL) so names match implementation (or
alternatively change the service to read RESPOND_COUNTLY_* if you prefer that
convention) — make the change consistently in both places and update any mention
of replacing RESPOND_APTABASE_* to reflect the chosen final names.

### 5. App Initialization
- **Integrated** analytics service into `AppInitializationService`
- **Automatic initialization** during app startup
- **Proper error handling** - analytics failures don't break app startup
- **Environment-based configuration** - uses env vars automatically

### 6. Removed Components
- ❌ **Deleted**: `src/components/common/aptabase-provider.tsx`
- ❌ **Removed**: All references to `AptabaseProviderWrapper` in `_layout.tsx`
- ❌ **Cleaned up**: Old Aptabase mocks in test files

### 7. Test Coverage
- ✅ **New comprehensive test suite** for `analytics.service.ts` (23 passing tests)
- ✅ **Updated hook tests** to use new service
- ✅ **Cleaned up** old Aptabase references in component tests
- ✅ **All tests passing** for analytics-related code

## Migration Benefits

### ✅ Minimal Disruption
- **Zero changes** required in components using analytics
- **Same API interface** maintained across the migration
- **Existing analytics calls** continue working unchanged

### ✅ Enhanced Reliability
- **Better error handling** with exponential backoff retry logic
- **Graceful degradation** when analytics fails
- **Service status monitoring** with automatic recovery
- **Proper session management**

### ✅ Improved Architecture
- **Centralized initialization** through AppInitializationService
- **Environment-driven configuration**
- **Comprehensive test coverage**
- **Better logging and debugging**

## Configuration Required

To complete the setup, you need to configure the environment variables:

```bash
# In your .env files (development, staging, production)
RESPOND_COUNTLY_APP_KEY=your_countly_app_key_here
RESPOND_COUNTLY_URL=https://your-countly-server.com
```

## Testing Status

✅ **All analytics tests passing**:
- `src/services/__tests__/analytics.service.test.ts` - 23 tests ✅
- `src/hooks/__tests__/use-analytics.test.ts` - All tests ✅
- Component integration tests working ✅

## Next Steps

1. **Configure Countly server credentials** in environment files
2. **Test in development environment** to verify data flow
3. **Deploy to staging** for validation
4. **Monitor analytics data** to ensure proper tracking

## Rollback Plan (if needed)

If rollback is required:
1. Reinstall: `yarn add @aptabase/react-native`
2. Restore: `src/services/aptabase.service.ts` from git history
3. Revert: Environment variable changes
4. Restore: `AptabaseProviderWrapper` in `_layout.tsx`

However, the migration maintains full API compatibility, so rollback should not be necessary.
5 changes: 0 additions & 5 deletions __mocks__/@aptabase/react-native.ts

This file was deleted.

8 changes: 8 additions & 0 deletions __mocks__/countly-sdk-react-native-bridge/CountlyConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default jest.fn().mockImplementation(() => ({
setLoggingEnabled: jest.fn().mockReturnThis(),
enableCrashReporting: jest.fn().mockReturnThis(),
setRequiresConsent: jest.fn().mockReturnThis(),
giveConsent: jest.fn().mockReturnThis(),
setLocation: jest.fn().mockReturnThis(),
enableParameterTamperingProtection: jest.fn().mockReturnThis(),
}));
20 changes: 20 additions & 0 deletions __mocks__/countly-sdk-react-native-bridge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const initWithConfig = jest.fn();
export const events = {
recordEvent: jest.fn(),
startEvent: jest.fn(),
endEvent: jest.fn(),
cancelEvent: jest.fn(),
};
export const setUserData = jest.fn();
export const endSession = jest.fn();
export const startSession = jest.fn();
export const isInitialized = jest.fn().mockResolvedValue(false);

export default {
initWithConfig,
events,
setUserData,
endSession,
startSession,
isInitialized,
};
8 changes: 4 additions & 4 deletions env.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ const client = z.object({
RESPOND_MAPBOX_DLKEY: z.string(),
IS_MOBILE_APP: z.boolean(),
SENTRY_DSN: z.string(),
APTABASE_URL: z.string(),
APTABASE_APP_KEY: z.string(),
COUNTLY_URL: z.string(),
COUNTLY_APP_KEY: z.string(),
STORAGE_ENCRYPTION_KEY: z.string().optional(),
});

Expand Down Expand Up @@ -128,8 +128,8 @@ const _clientEnv = {
RESPOND_MAPBOX_PUBKEY: process.env.RESPOND_MAPBOX_PUBKEY || '',
RESPOND_MAPBOX_DLKEY: process.env.RESPOND_MAPBOX_DLKEY || '',
SENTRY_DSN: process.env.RESPOND_SENTRY_DSN || '',
APTABASE_APP_KEY: process.env.RESPOND_APTABASE_APP_KEY || '',
APTABASE_URL: process.env.RESPOND_APTABASE_URL || '',
COUNTLY_APP_KEY: process.env.RESPOND_COUNTLY_APP_KEY || '',
COUNTLY_URL: process.env.RESPOND_COUNTLY_URL || '',
STORAGE_ENCRYPTION_KEY: process.env.RESPOND_STORAGE_ENCRYPTION_KEY || '',
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development"
},
"dependencies": {
"@aptabase/react-native": "^0.3.10",
"@config-plugins/react-native-callkeep": "^11.0.0",
"@config-plugins/react-native-webrtc": "~12.0.0",
"@dev-plugins/react-query": "~0.3.1",
Expand Down Expand Up @@ -94,6 +93,7 @@
"axios": "^1.11.0",
"babel-plugin-module-resolver": "^5.0.2",
"buffer": "^6.0.3",
"countly-sdk-react-native-bridge": "^25.4.0",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"expo": "~53.0.0",
Expand Down
85 changes: 63 additions & 22 deletions src/app/(app)/__tests__/map.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react-native';
import React from 'react';

import { useAnalytics } from '@/hooks/use-analytics';
Expand Down Expand Up @@ -184,21 +184,7 @@ jest.mock('@/components/sidebar/side-menu', () => ({
},
}));

jest.mock('@/components/ui/header', () => ({
Header: ({ title, onMenuPress, testID }: any) => {
const { View, Text, Pressable } = require('react-native');
return (
<View testID={testID}>
<Text testID={`${testID}-title`}>{title}</Text>
{onMenuPress && (
<Pressable testID={`${testID}-menu-button`} onPress={onMenuPress}>
<Text>Menu</Text>
</Pressable>
)}
</View>
);
},
}));


jest.mock('@/components/ui/focus-aware-status-bar', () => ({
FocusAwareStatusBar: () => 'FocusAwareStatusBar',
Expand Down Expand Up @@ -336,16 +322,21 @@ describe('HomeMap', () => {
});
});

it('renders correctly with map components', () => {
it('renders correctly with map components', async () => {
render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Check that map container is rendered
expect(screen.getByTestId('home-map-container')).toBeTruthy();
expect(screen.getByTestId('home-map-view')).toBeTruthy();
expect(screen.getByTestId('map-camera')).toBeTruthy();
});

it('shows side menu in landscape mode', () => {
it('shows side menu in landscape mode', async () => {
// Mock landscape dimensions
const mockUseWindowDimensions = (jest.requireMock('react-native') as any).useWindowDimensions;
mockUseWindowDimensions.mockReturnValue({
Expand All @@ -355,13 +346,23 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// In landscape mode, side menu should be permanently visible
expect(screen.getByTestId('side-menu')).toBeTruthy();
});

it('shows drawer in portrait mode when opened', async () => {
render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Initially drawer should not be visible
expect(screen.queryByTestId('drawer')).toBeNull();

Expand All @@ -380,6 +381,11 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Simulate map ready
await waitFor(() => {
expect(screen.getByTestId('home-map-view')).toBeTruthy();
Expand All @@ -400,6 +406,11 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

await waitFor(() => {
expect(screen.getByTestId('home-map-view')).toBeTruthy();
});
Expand Down Expand Up @@ -495,12 +506,17 @@ describe('HomeMap', () => {
it('shows user location marker when location is available', async () => {
render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

await waitFor(() => {
expect(screen.getByTestId('point-annotation')).toBeTruthy();
});
});

it('handles landscape mode correctly', () => {
it('handles landscape mode correctly', async () => {
// Mock landscape dimensions
const mockUseWindowDimensions = (jest.requireMock('react-native') as any).useWindowDimensions;
mockUseWindowDimensions.mockReturnValue({
Expand All @@ -510,12 +526,17 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// In landscape mode, side menu should be permanently visible
expect(screen.getByTestId('side-menu')).toBeTruthy();
});

describe('Analytics Tracking', () => {
it('tracks map view on focus', () => {
it('tracks map view on focus', async () => {
const mockLocationStore = jest.requireMock('@/stores/app/location-store') as any;
mockLocationStore.useLocationStore.mockReturnValue({
latitude: 40.7128,
Expand All @@ -526,6 +547,11 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Check analytics tracking for view
expect(mockTrackEvent).toHaveBeenCalledWith('map_viewed', {
timestamp: expect.any(String),
Expand Down Expand Up @@ -567,6 +593,11 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Wait for map to be ready and simulate user moving map
await waitFor(() => {
expect(screen.getByTestId('home-map-view')).toBeTruthy();
Expand Down Expand Up @@ -636,7 +667,7 @@ describe('HomeMap', () => {
});
});

it('tracks analytics with correct location data when location is unavailable', () => {
it('tracks analytics with correct location data when location is unavailable', async () => {
const mockLocationStore = jest.requireMock('@/stores/app/location-store') as any;
mockLocationStore.useLocationStore.mockReturnValue({
latitude: null,
Expand All @@ -647,6 +678,11 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Check analytics tracking for view without location
expect(mockTrackEvent).toHaveBeenCalledWith('map_viewed', {
timestamp: expect.any(String),
Expand All @@ -655,7 +691,7 @@ describe('HomeMap', () => {
});
});

it('tracks analytics with map locked state', () => {
it('tracks analytics with map locked state', async () => {
const mockLocationStore = jest.requireMock('@/stores/app/location-store') as any;
mockLocationStore.useLocationStore.mockReturnValue({
latitude: 40.7128,
Expand All @@ -666,6 +702,11 @@ describe('HomeMap', () => {

render(<HomeMap />);

// Wait for async map data to load
await waitFor(() => {
expect(screen.getByTestId('map-pins')).toBeTruthy();
});

// Check analytics tracking for view with locked map
expect(mockTrackEvent).toHaveBeenCalledWith('map_viewed', {
timestamp: expect.any(String),
Expand Down
Loading
Loading