From 5e84c8ee6d12d9059d65ed7a4bc51d16fcdef1bf Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 16 Jan 2026 14:48:14 +0800 Subject: [PATCH 1/9] v0.3.11 --- addon/services/app-cache.js | 3 +- addon/services/current-user.js | 51 +++++++++++++++++----------------- package.json | 2 +- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/addon/services/app-cache.js b/addon/services/app-cache.js index 4aaab00e..3a1ba75b 100644 --- a/addon/services/app-cache.js +++ b/addon/services/app-cache.js @@ -13,7 +13,8 @@ export default class AppCacheService extends Service { get cachePrefix() { const userId = this.currentUser.id ?? 'anon'; - return `${userId}:${this.currentUser.companyId}:`; + const companyId = this.currentUser.companyId ?? 'no-org'; + return `${userId}:${companyId}:`; } @action setEmberData(key, value, except = []) { diff --git a/addon/services/current-user.js b/addon/services/current-user.js index 8434877f..b174a9c4 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -65,14 +65,9 @@ export default class CurrentUserService extends Service.extend(Evented) { async load() { if (this.session.isAuthenticated) { const user = await this.store.findRecord('user', 'me'); - const snapshot = await this.getUserSnapshot(user); - this.set('user', user); - this.set('userSnapshot', snapshot); - this.trigger('user.loaded', user); - - // Set permissions - this.permissions = this.getUserPermissions(user); + // set user + this.setUser(user); // Load preferences await this.loadPreferences(); @@ -91,25 +86,9 @@ export default class CurrentUserService extends Service.extend(Evented) { try { const user = await this.store.queryRecord('user', { me: true }); - const snapshot = await this.getUserSnapshot(user); - - // Set current user - this.set('user', user); - this.set('userSnapshot', snapshot); - this.trigger('user.loaded', user); - - // Set permissions - this.permissions = this.getUserPermissions(user); - // Set environment from user option - this.theme.setEnvironment(); - - // Set locale - if (user.locale) { - this.setLocale(user.locale); - } else { - await this.loadLocale(); - } + // set user + this.setUser(user); // Load user whois data await this.loadWhois(); @@ -309,4 +288,26 @@ export default class CurrentUserService extends Service.extend(Evented) { return defaultValue; } + + async setUser(user) { + const snapshot = await this.getUserSnapshot(user); + + // Set current user + this.set('user', user); + this.set('userSnapshot', snapshot); + this.trigger('user.loaded', user); + + // Set permissions + this.permissions = this.getUserPermissions(user); + + // Set environment from user option + this.theme.setEnvironment(); + + // Set locale + if (user.locale) { + this.setLocale(user.locale); + } else { + await this.loadLocale(); + } + } } diff --git a/package.json b/package.json index fc7e85e3..d22efcb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-core", - "version": "0.3.10", + "version": "0.3.11", "description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.", "keywords": [ "fleetbase-core", From 58fa3a67905ce1e853505018d0ac85c0cf1b511c Mon Sep 17 00:00:00 2001 From: Manus AI Date: Sun, 8 Feb 2026 21:13:29 -0500 Subject: [PATCH 2/9] feat: Add universe-based analytics events to core services - Add analytics event triggering to crud service (delete, bulk actions, import) - Add analytics event triggering to resource-action service (create, update, delete) - Add session analytics events (initialized, terminated with duration) - Emit both generic (resource.created) and specific (order.created) events - Use dot notation naming convention for all events - Non-breaking changes, events are optional and don't affect existing functionality --- addon/services/crud.js | 37 +++++++++++++++++++++++++++++++ addon/services/resource-action.js | 31 ++++++++++++++++++++++++++ addon/services/session.js | 16 +++++++++++++ 3 files changed, 84 insertions(+) diff --git a/addon/services/crud.js b/addon/services/crud.js index 2e7beff1..1fa7489b 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -17,6 +17,7 @@ export default class CrudService extends Service { @service notifications; @service store; @service currentUser; + @service universe; /** * Generic deletion modal with options @@ -43,6 +44,10 @@ export default class CrudService extends Service { try { const response = await model.destroyRecord(); this.notifications.success(successNotification); + + // Trigger analytics events + this._triggerResourceEvent('deleted', model); + if (typeof options.onSuccess === 'function') { options.onSuccess(model); } @@ -161,6 +166,13 @@ export default class CrudService extends Service { ); this.notifications.success(response.message ?? successMessage); + + // Trigger bulk action event + if (verb === 'delete') { + selected.forEach(model => this._triggerResourceEvent('deleted', model)); + } + this.universe.trigger('resource.bulk_action', verb, selected, firstModel); + if (typeof options.onSuccess === 'function') { options.onSuccess(selected); } @@ -248,6 +260,25 @@ export default class CrudService extends Service { * @param {Object} [options={}] * @memberof CrudService */ + /** + * Helper method to trigger both generic and specific resource events + * + * @private + * @param {String} action - The action performed (created, updated, deleted, etc.) + * @param {Model} model - The Ember Data model + */ + _triggerResourceEvent(action, model) { + // Trigger generic resource event + this.universe.trigger(`resource.${action}`, model); + + // Trigger specific model event (e.g., order.created, vehicle.updated) + const modelName = getModelName(model); + if (modelName) { + const specificModelName = dasherize(modelName).replace(/-/g, '_'); + this.universe.trigger(`${specificModelName}.${action}`, model); + } + } + @action import(modelName, options = {}) { // always lowercase modelname modelName = modelName.toLowerCase(); @@ -337,6 +368,12 @@ export default class CrudService extends Service { try { const response = await this.fetch.post(importEndpoint, { files }, fetchOptions); + + // Trigger import event + this.universe.trigger('resource.imported', modelName, response, files); + const specificModelName = dasherize(modelName).replace(/-/g, '_'); + this.universe.trigger(`${specificModelName}.imported`, response, files); + if (typeof options.onImportCompleted === 'function') { options.onImportCompleted(response, files); } diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js index f79d1e25..a2bcbcd6 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -29,6 +29,7 @@ export default class ResourceActionService extends Service { @service abilities; @service tableContext; @service resourceContextPanel; + @service universe; /** * Getter for router, attempt to use hostRouter if from engine @@ -299,6 +300,9 @@ export default class ResourceActionService extends Service { }) ); + // Trigger analytics events + this._triggerResourceEvent('created', record); + if (options.refresh) { this.refresh(); } @@ -329,6 +333,9 @@ export default class ResourceActionService extends Service { }) ); + // Trigger analytics events + this._triggerResourceEvent('updated', record); + if (options.refresh) { this.refresh(); } @@ -362,6 +369,9 @@ export default class ResourceActionService extends Service { }) ); + // Trigger analytics events + this._triggerResourceEvent(isNew ? 'created' : 'updated', record); + if (options.refresh) { this.refresh(); } @@ -409,6 +419,9 @@ export default class ResourceActionService extends Service { }) ); + // Trigger analytics events + this._triggerResourceEvent('deleted', record); + if (options.refresh) { this.refresh(); } @@ -424,6 +437,24 @@ export default class ResourceActionService extends Service { } } + /** + * Helper method to trigger both generic and specific resource events + * + * @private + * @param {String} action - The action performed (created, updated, deleted, etc.) + * @param {Model} record - The Ember Data model + */ + _triggerResourceEvent(action, record) { + // Trigger generic resource event + this.universe.trigger(`resource.${action}`, record); + + // Trigger specific model event using the service's modelName + if (this.modelName) { + const specificModelName = this.modelName.replace(/-/g, '_'); + this.universe.trigger(`${specificModelName}.${action}`, record); + } + } + /** * Searches for records with debouncing. * Uses ember-concurrency for async handling with restartable behavior. diff --git a/addon/services/session.js b/addon/services/session.js index f78f4fb7..4aaa5a04 100644 --- a/addon/services/session.js +++ b/addon/services/session.js @@ -10,6 +10,7 @@ export default class SessionService extends SimpleAuthSessionService { @service currentUser; @service fetch; @service notifications; + @service universe; /** * Set where to transition to @@ -18,6 +19,13 @@ export default class SessionService extends SimpleAuthSessionService { */ @tracked redirectTo = 'console'; + /** + * Track session start time for duration calculation + * + * @var {Date} + */ + @tracked sessionStartTime = null; + /** * If session is onboarding a user. * @@ -89,6 +97,10 @@ export default class SessionService extends SimpleAuthSessionService { return this.invalidateWithLoader('Session authentication failed...'); } + // Track session initialization + this.sessionStartTime = new Date(); + this.universe.trigger('session.initialized', user, user.organization); + return user; } catch (error) { await this.invalidateWithLoader(getWithDefault(error, 'message', 'Session authentication failed...')); @@ -155,6 +167,10 @@ export default class SessionService extends SimpleAuthSessionService { * @return {Promise} */ invalidateWithLoader(loadingMessage = 'Ending session...') { + // Track session termination + const sessionDuration = this.sessionStartTime ? Math.round((new Date() - this.sessionStartTime) / 1000) : null; + this.universe.trigger('session.terminated', this.currentUser.user, sessionDuration); + // if loader node is open already just invalidate if (this.isLoaderNodeOpen === true) { return this.session.invalidate(); From 97960c06ebd23e0d748aa8f5de80f665787b2ac0 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Sun, 8 Feb 2026 21:23:37 -0500 Subject: [PATCH 3/9] fix: Add export tracking and remove incorrect session tracking - Add resource.exported and {model}.exported events to crud service export method - Remove incorrect session tracking from session service (was in wrong place) - Session events should be tracked via current-user service's existing 'user.loaded' event - Current-user service already extends Evented and triggers 'user.loaded' on lines 72 and 99 The current-user service is the proper place for user/session lifecycle events since it: 1. Already extends Evented mixin 2. Already triggers 'user.loaded' event when user is authenticated 3. Is called by session.loadCurrentUser() and session.promiseCurrentUser() 4. Has access to user and organization data For logout/session termination tracking, consumers should subscribe to ember-simple-auth's 'invalidationSucceeded' event or add a 'user.logout' event to current-user service. --- addon/services/crud.js | 5 +++++ addon/services/session.js | 16 ---------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/addon/services/crud.js b/addon/services/crud.js index 1fa7489b..52a2d50b 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -236,6 +236,11 @@ export default class CrudService extends Service { } ) .then(() => { + // Trigger export event + this.universe.trigger('resource.exported', modelName, format, exportParams); + const specificModelName = dasherize(modelName).replace(/-/g, '_'); + this.universe.trigger(`${specificModelName}.exported`, format, exportParams); + later( this, () => { diff --git a/addon/services/session.js b/addon/services/session.js index 4aaa5a04..f78f4fb7 100644 --- a/addon/services/session.js +++ b/addon/services/session.js @@ -10,7 +10,6 @@ export default class SessionService extends SimpleAuthSessionService { @service currentUser; @service fetch; @service notifications; - @service universe; /** * Set where to transition to @@ -19,13 +18,6 @@ export default class SessionService extends SimpleAuthSessionService { */ @tracked redirectTo = 'console'; - /** - * Track session start time for duration calculation - * - * @var {Date} - */ - @tracked sessionStartTime = null; - /** * If session is onboarding a user. * @@ -97,10 +89,6 @@ export default class SessionService extends SimpleAuthSessionService { return this.invalidateWithLoader('Session authentication failed...'); } - // Track session initialization - this.sessionStartTime = new Date(); - this.universe.trigger('session.initialized', user, user.organization); - return user; } catch (error) { await this.invalidateWithLoader(getWithDefault(error, 'message', 'Session authentication failed...')); @@ -167,10 +155,6 @@ export default class SessionService extends SimpleAuthSessionService { * @return {Promise} */ invalidateWithLoader(loadingMessage = 'Ending session...') { - // Track session termination - const sessionDuration = this.sessionStartTime ? Math.round((new Date() - this.sessionStartTime) / 1000) : null; - this.universe.trigger('session.terminated', this.currentUser.user, sessionDuration); - // if loader node is open already just invalidate if (this.isLoaderNodeOpen === true) { return this.session.invalidate(); From d6fc8a3f793408fd501095007d5a457fb418e14c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 10 Feb 2026 11:45:28 +0800 Subject: [PATCH 4/9] fixed linter --- addon/services/crud.js | 18 +++++++++--------- addon/services/resource-action.js | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/addon/services/crud.js b/addon/services/crud.js index 52a2d50b..dca8c89b 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -44,10 +44,10 @@ export default class CrudService extends Service { try { const response = await model.destroyRecord(); this.notifications.success(successNotification); - + // Trigger analytics events this._triggerResourceEvent('deleted', model); - + if (typeof options.onSuccess === 'function') { options.onSuccess(model); } @@ -166,13 +166,13 @@ export default class CrudService extends Service { ); this.notifications.success(response.message ?? successMessage); - + // Trigger bulk action event if (verb === 'delete') { - selected.forEach(model => this._triggerResourceEvent('deleted', model)); + selected.forEach((model) => this._triggerResourceEvent('deleted', model)); } this.universe.trigger('resource.bulk_action', verb, selected, firstModel); - + if (typeof options.onSuccess === 'function') { options.onSuccess(selected); } @@ -240,7 +240,7 @@ export default class CrudService extends Service { this.universe.trigger('resource.exported', modelName, format, exportParams); const specificModelName = dasherize(modelName).replace(/-/g, '_'); this.universe.trigger(`${specificModelName}.exported`, format, exportParams); - + later( this, () => { @@ -275,7 +275,7 @@ export default class CrudService extends Service { _triggerResourceEvent(action, model) { // Trigger generic resource event this.universe.trigger(`resource.${action}`, model); - + // Trigger specific model event (e.g., order.created, vehicle.updated) const modelName = getModelName(model); if (modelName) { @@ -373,12 +373,12 @@ export default class CrudService extends Service { try { const response = await this.fetch.post(importEndpoint, { files }, fetchOptions); - + // Trigger import event this.universe.trigger('resource.imported', modelName, response, files); const specificModelName = dasherize(modelName).replace(/-/g, '_'); this.universe.trigger(`${specificModelName}.imported`, response, files); - + if (typeof options.onImportCompleted === 'function') { options.onImportCompleted(response, files); } diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js index a2bcbcd6..62827c43 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -447,7 +447,7 @@ export default class ResourceActionService extends Service { _triggerResourceEvent(action, record) { // Trigger generic resource event this.universe.trigger(`resource.${action}`, record); - + // Trigger specific model event using the service's modelName if (this.modelName) { const specificModelName = this.modelName.replace(/-/g, '_'); From bad3418d4c1637800bf10823b297e566aaaa5938 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 10 Feb 2026 20:38:13 -0500 Subject: [PATCH 5/9] feat: Add dedicated analytics service with private # methods - Created centralized analytics service for event tracking - Refactored crud, resource-action, and current-user services to use analytics service - Removed scattered _triggerResourceEvent() methods - Added global service exports via host-services and services - Implemented private methods using native # syntax - Added comprehensive documentation in ANALYTICS_SERVICE.md Benefits: - Single responsibility: One service handles all analytics - Reusable: Any engine can inject and use it - Consistent API: Standardized methods for tracking - Cleaner services: CRUD/resource-action stay focused - Better testability: Can mock/stub analytics service easily --- ANALYTICS_SERVICE.md | 425 ++++++++++++++++++++++++++++++ addon/exports/host-services.js | 1 + addon/exports/services.js | 1 + addon/services/analytics.js | 318 ++++++++++++++++++++++ addon/services/crud.js | 44 +--- addon/services/current-user.js | 7 + addon/services/resource-action.js | 39 +-- app/services/analytics.js | 1 + 8 files changed, 778 insertions(+), 58 deletions(-) create mode 100644 ANALYTICS_SERVICE.md create mode 100644 addon/services/analytics.js create mode 100644 app/services/analytics.js diff --git a/ANALYTICS_SERVICE.md b/ANALYTICS_SERVICE.md new file mode 100644 index 00000000..7025bc0c --- /dev/null +++ b/ANALYTICS_SERVICE.md @@ -0,0 +1,425 @@ +# Analytics Service + +The `analytics` service provides a centralized, analytics-agnostic event tracking system for Fleetbase. It emits standardized events via the `universe` service's event bus, allowing engines (like `internals`) to subscribe and implement their own analytics integrations. + +## Overview + +The analytics service is designed to: + +- **Centralize event emission** - Single source of truth for all analytics events +- **Remain analytics-agnostic** - No vendor-specific code (PostHog, Google Analytics, etc.) +- **Use the universe event bus** - Events are published via `universe.trigger()` +- **Be opt-in** - Services and components must explicitly call tracking methods +- **Enrich events automatically** - Adds user, organization, and timestamp context + +## Architecture + +``` +Service/Component + ↓ +analytics.trackResourceCreated(order) + ↓ +Analytics Service + - Enriches with metadata + - Formats payload + ↓ +universe.trigger('resource.created', ...) +universe.trigger('order.created', ...) + ↓ +Internals (or other engines) + - Listens via universe.on() + - Translates to PostHog/etc +``` + +## Installation + +The analytics service is automatically available in all engines and the console application. It's exported globally via `host-services` and `services`. + +### Injection + +```javascript +import { inject as service } from '@ember/service'; + +export default class MyService extends Service { + @service analytics; + + async createOrder(orderData) { + const order = await this.store.createRecord('order', orderData).save(); + this.analytics.trackResourceCreated(order); + return order; + } +} +``` + +## API Reference + +### Resource Tracking + +#### `trackResourceCreated(resource, props = {})` + +Tracks the creation of a new resource. + +**Parameters:** +- `resource` (Object) - The created Ember Data model +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `resource.created` (generic) +- `{modelName}.created` (specific, e.g., `order.created`) + +**Example:** +```javascript +this.analytics.trackResourceCreated(order); +// Emits: resource.created, order.created +``` + +#### `trackResourceUpdated(resource, props = {})` + +Tracks the update of an existing resource. + +**Parameters:** +- `resource` (Object) - The updated Ember Data model +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `resource.updated` (generic) +- `{modelName}.updated` (specific, e.g., `driver.updated`) + +**Example:** +```javascript +this.analytics.trackResourceUpdated(driver); +// Emits: resource.updated, driver.updated +``` + +#### `trackResourceDeleted(resource, props = {})` + +Tracks the deletion of a resource. + +**Parameters:** +- `resource` (Object) - The deleted Ember Data model +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `resource.deleted` (generic) +- `{modelName}.deleted` (specific, e.g., `vehicle.deleted`) + +**Example:** +```javascript +this.analytics.trackResourceDeleted(vehicle); +// Emits: resource.deleted, vehicle.deleted +``` + +#### `trackResourceImported(modelName, count, props = {})` + +Tracks a bulk import of resources. + +**Parameters:** +- `modelName` (String) - The name of the model being imported +- `count` (Number) - Number of resources imported +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `resource.imported` + +**Example:** +```javascript +this.analytics.trackResourceImported('contact', 50); +// Emits: resource.imported with count: 50 +``` + +#### `trackResourceExported(modelName, format, params = {}, props = {})` + +Tracks a resource export. + +**Parameters:** +- `modelName` (String) - The name of the model being exported +- `format` (String) - Export format (csv, xlsx, pdf, etc.) +- `params` (Object, optional) - Export parameters/filters +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `resource.exported` (generic) +- `{modelName}.exported` (specific, e.g., `order.exported`) + +**Example:** +```javascript +this.analytics.trackResourceExported('order', 'csv', { status: 'completed' }); +// Emits: resource.exported, order.exported +``` + +#### `trackBulkAction(verb, resources, props = {})` + +Tracks a bulk action on multiple resources. + +**Parameters:** +- `verb` (String) - The action verb (delete, archive, etc.) +- `resources` (Array) - Array of selected resources +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `resource.bulk_action` + +**Example:** +```javascript +this.analytics.trackBulkAction('delete', selectedOrders); +// Emits: resource.bulk_action with count and action +``` + +### Session Tracking + +#### `trackUserLoaded(user, organization, props = {})` + +Tracks when the current user is loaded (session initialized). + +**Parameters:** +- `user` (Object) - The user object +- `organization` (Object) - The organization object +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `user.loaded` + +**Example:** +```javascript +this.analytics.trackUserLoaded(user, organization); +// Emits: user.loaded +``` + +#### `trackSessionTerminated(duration, props = {})` + +Tracks when a user session ends. + +**Parameters:** +- `duration` (Number) - Session duration in seconds +- `props` (Object, optional) - Additional properties to include + +**Events Emitted:** +- `session.terminated` + +**Example:** +```javascript +this.analytics.trackSessionTerminated(3600); +// Emits: session.terminated with duration +``` + +### Custom Events + +#### `trackEvent(eventName, props = {})` + +Tracks a generic custom event. + +**Parameters:** +- `eventName` (String) - The event name (dot notation recommended) +- `props` (Object, optional) - Event properties + +**Events Emitted:** +- `{eventName}` (as specified) + +**Example:** +```javascript +this.analytics.trackEvent('chat.message.sent', { length: 140 }); +// Emits: chat.message.sent +``` + +### Utility Methods + +#### `isEnabled()` + +Checks if analytics tracking is enabled. + +**Returns:** `Boolean` + +**Example:** +```javascript +if (this.analytics.isEnabled()) { + // Tracking is enabled +} +``` + +## Configuration + +The analytics service can be configured via `config/environment.js`: + +```javascript +// config/environment.js +ENV.analytics = { + enabled: true, // Master switch (default: true) + debug: false, // Log events to console (default: false) + enrich: { + user: true, // Add user_id to events (default: true) + organization: true, // Add organization_id to events (default: true) + timestamp: true // Add timestamp to events (default: true) + } +}; +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | Boolean | `true` | Master switch to enable/disable all tracking | +| `debug` | Boolean | `false` | Log events to console for debugging | +| `enrich.user` | Boolean | `true` | Add `user_id` to all events | +| `enrich.organization` | Boolean | `true` | Add `organization_id` to all events | +| `enrich.timestamp` | Boolean | `true` | Add `timestamp` to all events | + +## Event Naming Convention + +All events use **dot notation** with the following patterns: + +- **Generic resource events:** `resource.{action}` (e.g., `resource.created`) +- **Specific resource events:** `{modelName}.{action}` (e.g., `order.created`) +- **Session events:** `user.loaded`, `session.terminated` +- **Custom events:** Use dot notation (e.g., `chat.message.sent`) + +## Event Properties + +All events are automatically enriched with the following properties (if enabled): + +- `user_id` - Current user's ID +- `organization_id` - Current organization's ID +- `timestamp` - ISO 8601 timestamp + +Resource events also include: + +- `id` - Resource ID +- `model_name` - Model name +- `name` - Resource name (if available) +- `status` - Resource status (if available) +- `type` - Resource type (if available) + +## Usage Examples + +### In a Service + +```javascript +import Service, { inject as service } from '@ember/service'; + +export default class OrderService extends Service { + @service store; + @service analytics; + + async createOrder(orderData) { + const order = this.store.createRecord('order', orderData); + await order.save(); + + // Track the creation + this.analytics.trackResourceCreated(order); + + return order; + } + + async updateOrder(order, updates) { + order.setProperties(updates); + await order.save(); + + // Track the update + this.analytics.trackResourceUpdated(order); + + return order; + } +} +``` + +### In a Component + +```javascript +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class OrderFormComponent extends Component { + @service analytics; + + @action + async saveOrder() { + await this.args.order.save(); + + // Track the save + if (this.args.order.isNew) { + this.analytics.trackResourceCreated(this.args.order); + } else { + this.analytics.trackResourceUpdated(this.args.order); + } + } +} +``` + +### Listening to Events (in Engines) + +```javascript +// In an engine's instance-initializer +export function initialize(owner) { + const universe = owner.lookup('service:universe'); + const posthog = owner.lookup('service:posthog'); + + // Listen to order creation events + universe.on('order.created', (order, properties) => { + posthog.trackEvent('order_created', { + order_id: order.id, + ...properties + }); + }); + + // Listen to all resource creation events + universe.on('resource.created', (resource, properties) => { + posthog.trackEvent('resource_created', properties); + }); +} +``` + +## Best Practices + +1. **Use specific tracking methods** - Prefer `trackResourceCreated()` over `trackEvent()` +2. **Track after success** - Only track events after the operation succeeds +3. **Keep properties minimal** - Only include necessary data +4. **Use dot notation** - For custom event names (e.g., `chat.message.sent`) +5. **Don't track sensitive data** - Avoid passwords, tokens, payment info +6. **Test with debug mode** - Set `analytics.debug: true` in development + +## Testing + +To stub the analytics service in tests: + +```javascript +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import sinon from 'sinon'; + +module('Unit | Service | order', function(hooks) { + setupTest(hooks); + + test('it tracks order creation', async function(assert) { + const service = this.owner.lookup('service:order'); + const analytics = this.owner.lookup('service:analytics'); + + // Stub the tracking method + const trackStub = sinon.stub(analytics, 'trackResourceCreated'); + + await service.createOrder({ name: 'Test Order' }); + + // Assert tracking was called + assert.ok(trackStub.calledOnce); + }); +}); +``` + +## Migration from Direct universe.trigger() + +If you previously used `universe.trigger()` directly: + +**Before:** +```javascript +this.universe.trigger('resource.created', model); +this.universe.trigger('order.created', model); +``` + +**After:** +```javascript +this.analytics.trackResourceCreated(model); +// Automatically emits both events +``` + +## Support + +For questions or issues with the analytics service, please refer to the Fleetbase documentation or contact the development team. diff --git a/addon/exports/host-services.js b/addon/exports/host-services.js index cc8d3f69..91830230 100644 --- a/addon/exports/host-services.js +++ b/addon/exports/host-services.js @@ -19,6 +19,7 @@ export const hostServices = [ 'sidebar', 'dashboard', 'universe', + 'analytics', 'intl', 'abilities', 'language', diff --git a/addon/exports/services.js b/addon/exports/services.js index 0f728bb9..79fa36c5 100644 --- a/addon/exports/services.js +++ b/addon/exports/services.js @@ -21,6 +21,7 @@ export const services = [ 'sidebar', 'dashboard', 'universe', + 'analytics', 'intl', 'abilities', 'language', diff --git a/addon/services/analytics.js b/addon/services/analytics.js new file mode 100644 index 00000000..d52172e9 --- /dev/null +++ b/addon/services/analytics.js @@ -0,0 +1,318 @@ +import Service, { inject as service } from '@ember/service'; +import { getOwner } from '@ember/application'; +import config from 'ember-get-config'; + +/** + * Analytics Service + * + * Provides a centralized, analytics-agnostic event tracking system for Fleetbase. + * This service emits standardized events via the universe service's event bus, + * allowing engines (like internals) to subscribe and implement their own analytics. + * + * @class AnalyticsService + * @extends Service + */ +export default class AnalyticsService extends Service { + @service universe; + @service currentUser; + + /** + * Tracks the creation of a resource + * + * @param {Object} resource - The created resource/model + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceCreated(resource, props = {}) { + const events = this.#getResourceEvents(resource, 'created'); + const properties = this.#enrichProperties({ + ...this.#getSafeProperties(resource), + ...props + }); + + events.forEach(eventName => { + this.#trigger(eventName, resource, properties); + }); + } + + /** + * Tracks the update of a resource + * + * @param {Object} resource - The updated resource/model + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceUpdated(resource, props = {}) { + const events = this.#getResourceEvents(resource, 'updated'); + const properties = this.#enrichProperties({ + ...this.#getSafeProperties(resource), + ...props + }); + + events.forEach(eventName => { + this.#trigger(eventName, resource, properties); + }); + } + + /** + * Tracks the deletion of a resource + * + * @param {Object} resource - The deleted resource/model + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceDeleted(resource, props = {}) { + const events = this.#getResourceEvents(resource, 'deleted'); + const properties = this.#enrichProperties({ + ...this.#getSafeProperties(resource), + ...props + }); + + events.forEach(eventName => { + this.#trigger(eventName, resource, properties); + }); + } + + /** + * Tracks a bulk import of resources + * + * @param {String} modelName - The name of the model being imported + * @param {Number} count - Number of resources imported + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceImported(modelName, count, props = {}) { + const properties = this.#enrichProperties({ + model_name: modelName, + count: count, + ...props + }); + + this.#trigger('resource.imported', modelName, count, properties); + } + + /** + * Tracks a resource export + * + * @param {String} modelName - The name of the model being exported + * @param {String} format - Export format (csv, xlsx, etc.) + * @param {Object} [params={}] - Export parameters/filters + * @param {Object} [props={}] - Additional properties to include + */ + trackResourceExported(modelName, format, params = {}, props = {}) { + const properties = this.#enrichProperties({ + model_name: modelName, + export_format: format, + has_filters: !!(params && Object.keys(params).length > 0), + ...props + }); + + this.#trigger('resource.exported', modelName, format, params, properties); + + // Also trigger model-specific export event + const specificEvent = `${modelName}.exported`; + this.#trigger(specificEvent, modelName, format, params, properties); + } + + /** + * Tracks a bulk action on multiple resources + * + * @param {String} verb - The action verb (delete, archive, etc.) + * @param {Array} resources - Array of selected resources + * @param {Object} [props={}] - Additional properties to include + */ + trackBulkAction(verb, resources, props = {}) { + const firstResource = resources && resources.length > 0 ? resources[0] : null; + const modelName = this.#getModelName(firstResource); + + const properties = this.#enrichProperties({ + action: verb, + count: resources?.length || 0, + model_name: modelName, + ...props + }); + + this.#trigger('resource.bulk_action', verb, resources, firstResource, properties); + } + + /** + * Tracks when the current user is loaded (session initialized) + * + * @param {Object} user - The user object + * @param {Object} organization - The organization object + * @param {Object} [props={}] - Additional properties to include + */ + trackUserLoaded(user, organization, props = {}) { + const properties = this.#enrichProperties({ + user_id: user?.id, + organization_id: organization?.id, + organization_name: organization?.name, + ...props + }); + + this.#trigger('user.loaded', user, organization, properties); + } + + /** + * Tracks when a user session is terminated + * + * @param {Number} duration - Session duration in seconds + * @param {Object} [props={}] - Additional properties to include + */ + trackSessionTerminated(duration, props = {}) { + const properties = this.#enrichProperties({ + session_duration: duration, + ...props + }); + + this.#trigger('session.terminated', duration, properties); + } + + /** + * Tracks a generic custom event + * + * @param {String} eventName - The event name (dot notation) + * @param {Object} [props={}] - Event properties + */ + trackEvent(eventName, props = {}) { + const properties = this.#enrichProperties(props); + this.#trigger(eventName, properties); + } + + /** + * Checks if analytics tracking is enabled + * + * @returns {Boolean} + */ + isEnabled() { + const analyticsConfig = config?.analytics || {}; + return analyticsConfig.enabled !== false; // Enabled by default + } + + // ========================================================================= + // Private Methods (using # syntax) + // ========================================================================= + + /** + * Triggers an event on the universe service + * + * @private + * @param {String} eventName - The event name + * @param {...*} args - Arguments to pass to event listeners + */ + #trigger(eventName, ...args) { + if (!this.isEnabled()) { + return; + } + + if (!this.universe) { + console.warn('[Analytics] Universe service not available'); + return; + } + + // Debug logging if enabled + if (config?.analytics?.debug) { + console.log(`[Analytics] ${eventName}`, args); + } + + this.universe.trigger(eventName, ...args); + } + + /** + * Generates both generic and specific event names for a resource action + * + * @private + * @param {Object} resource - The resource/model + * @param {String} action - The action (created, updated, deleted) + * @returns {Array} Array of event names + */ + #getResourceEvents(resource, action) { + const modelName = this.#getModelName(resource); + return [ + `resource.${action}`, + `${modelName}.${action}` + ]; + } + + /** + * Extracts safe, serializable properties from a resource + * + * @private + * @param {Object} resource - The resource/model + * @returns {Object} Safe properties object + */ + #getSafeProperties(resource) { + if (!resource) { + return {}; + } + + const props = { + id: resource.id, + model_name: this.#getModelName(resource) + }; + + // Add common properties if available + const commonProps = ['name', 'status', 'type', 'slug', 'public_id']; + commonProps.forEach(prop => { + if (resource[prop] !== undefined && resource[prop] !== null) { + props[prop] = resource[prop]; + } + }); + + return props; + } + + /** + * Enriches properties with user, organization, and timestamp context + * + * @private + * @param {Object} props - Base properties + * @returns {Object} Enriched properties + */ + #enrichProperties(props = {}) { + const analyticsConfig = config?.analytics || {}; + const enrichConfig = analyticsConfig.enrich || {}; + const enriched = { ...props }; + + // Add user context if enabled + if (enrichConfig.user !== false && this.currentUser?.user) { + enriched.user_id = this.currentUser.user.id; + } + + // Add organization context if enabled + if (enrichConfig.organization !== false && this.currentUser?.organization) { + enriched.organization_id = this.currentUser.organization.id; + } + + // Add timestamp if enabled + if (enrichConfig.timestamp !== false) { + enriched.timestamp = new Date().toISOString(); + } + + return enriched; + } + + /** + * Safely extracts the model name from a resource + * + * @private + * @param {Object} resource - The resource/model + * @returns {String} Model name or 'unknown' + */ + #getModelName(resource) { + if (!resource) { + return 'unknown'; + } + + // Try multiple ways to get model name + if (resource.constructor?.modelName) { + return resource.constructor.modelName; + } + + if (resource._internalModel?.modelName) { + return resource._internalModel.modelName; + } + + if (resource.modelName) { + return resource.modelName; + } + + return 'unknown'; + } +} diff --git a/addon/services/crud.js b/addon/services/crud.js index dca8c89b..53a8e24d 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -18,6 +18,7 @@ export default class CrudService extends Service { @service store; @service currentUser; @service universe; + @service analytics; /** * Generic deletion modal with options @@ -45,8 +46,8 @@ export default class CrudService extends Service { const response = await model.destroyRecord(); this.notifications.success(successNotification); - // Trigger analytics events - this._triggerResourceEvent('deleted', model); + // Track deletion event + this.analytics.trackResourceDeleted(model); if (typeof options.onSuccess === 'function') { options.onSuccess(model); @@ -167,11 +168,8 @@ export default class CrudService extends Service { this.notifications.success(response.message ?? successMessage); - // Trigger bulk action event - if (verb === 'delete') { - selected.forEach((model) => this._triggerResourceEvent('deleted', model)); - } - this.universe.trigger('resource.bulk_action', verb, selected, firstModel); + // Track bulk action event + this.analytics.trackBulkAction(verb, selected); if (typeof options.onSuccess === 'function') { options.onSuccess(selected); @@ -236,10 +234,8 @@ export default class CrudService extends Service { } ) .then(() => { - // Trigger export event - this.universe.trigger('resource.exported', modelName, format, exportParams); - const specificModelName = dasherize(modelName).replace(/-/g, '_'); - this.universe.trigger(`${specificModelName}.exported`, format, exportParams); + // Track export event + this.analytics.trackResourceExported(modelName, format, exportParams); later( this, @@ -265,24 +261,7 @@ export default class CrudService extends Service { * @param {Object} [options={}] * @memberof CrudService */ - /** - * Helper method to trigger both generic and specific resource events - * - * @private - * @param {String} action - The action performed (created, updated, deleted, etc.) - * @param {Model} model - The Ember Data model - */ - _triggerResourceEvent(action, model) { - // Trigger generic resource event - this.universe.trigger(`resource.${action}`, model); - - // Trigger specific model event (e.g., order.created, vehicle.updated) - const modelName = getModelName(model); - if (modelName) { - const specificModelName = dasherize(modelName).replace(/-/g, '_'); - this.universe.trigger(`${specificModelName}.${action}`, model); - } - } + @action import(modelName, options = {}) { // always lowercase modelname @@ -374,10 +353,9 @@ export default class CrudService extends Service { try { const response = await this.fetch.post(importEndpoint, { files }, fetchOptions); - // Trigger import event - this.universe.trigger('resource.imported', modelName, response, files); - const specificModelName = dasherize(modelName).replace(/-/g, '_'); - this.universe.trigger(`${specificModelName}.imported`, response, files); + // Track import event + const importCount = response?.imported?.length || response?.count || files.length; + this.analytics.trackResourceImported(modelName, importCount); if (typeof options.onImportCompleted === 'function') { options.onImportCompleted(response, files); diff --git a/addon/services/current-user.js b/addon/services/current-user.js index 8434877f..2db2930d 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -16,6 +16,7 @@ export default class CurrentUserService extends Service.extend(Evented) { @service theme; @service notifications; @service intl; + @service analytics; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; @@ -71,6 +72,9 @@ export default class CurrentUserService extends Service.extend(Evented) { this.set('userSnapshot', snapshot); this.trigger('user.loaded', user); + // Track user loaded event + this.analytics.trackUserLoaded(user, user.company); + // Set permissions this.permissions = this.getUserPermissions(user); @@ -98,6 +102,9 @@ export default class CurrentUserService extends Service.extend(Evented) { this.set('userSnapshot', snapshot); this.trigger('user.loaded', user); + // Track user loaded event + this.analytics.trackUserLoaded(user, user.company); + // Set permissions this.permissions = this.getUserPermissions(user); diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js index 62827c43..62999a55 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -30,6 +30,7 @@ export default class ResourceActionService extends Service { @service tableContext; @service resourceContextPanel; @service universe; + @service analytics; /** * Getter for router, attempt to use hostRouter if from engine @@ -300,8 +301,8 @@ export default class ResourceActionService extends Service { }) ); - // Trigger analytics events - this._triggerResourceEvent('created', record); + // Track creation event + this.analytics.trackResourceCreated(record); if (options.refresh) { this.refresh(); @@ -333,8 +334,8 @@ export default class ResourceActionService extends Service { }) ); - // Trigger analytics events - this._triggerResourceEvent('updated', record); + // Track update event + this.analytics.trackResourceUpdated(record); if (options.refresh) { this.refresh(); @@ -369,8 +370,12 @@ export default class ResourceActionService extends Service { }) ); - // Trigger analytics events - this._triggerResourceEvent(isNew ? 'created' : 'updated', record); + // Track save event (create or update) + if (isNew) { + this.analytics.trackResourceCreated(record); + } else { + this.analytics.trackResourceUpdated(record); + } if (options.refresh) { this.refresh(); @@ -419,8 +424,8 @@ export default class ResourceActionService extends Service { }) ); - // Trigger analytics events - this._triggerResourceEvent('deleted', record); + // Track deletion event + this.analytics.trackResourceDeleted(record); if (options.refresh) { this.refresh(); @@ -437,23 +442,7 @@ export default class ResourceActionService extends Service { } } - /** - * Helper method to trigger both generic and specific resource events - * - * @private - * @param {String} action - The action performed (created, updated, deleted, etc.) - * @param {Model} record - The Ember Data model - */ - _triggerResourceEvent(action, record) { - // Trigger generic resource event - this.universe.trigger(`resource.${action}`, record); - - // Trigger specific model event using the service's modelName - if (this.modelName) { - const specificModelName = this.modelName.replace(/-/g, '_'); - this.universe.trigger(`${specificModelName}.${action}`, record); - } - } + /** * Searches for records with debouncing. diff --git a/app/services/analytics.js b/app/services/analytics.js new file mode 100644 index 00000000..37069d48 --- /dev/null +++ b/app/services/analytics.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/services/analytics'; From 62ad87a1950339db2260330163d984e038c186e6 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 10 Feb 2026 20:44:14 -0500 Subject: [PATCH 6/9] feat: Add dual event system to analytics service - Analytics service now extends Evented - Events fire on both analytics service and universe service - Allows local listeners (analytics.on) and cross-engine listeners (universe.on) - Updated documentation with dual event system examples - Follows Fleetbase patterns (like current-user service) Benefits: - Local listeners for UI updates and debugging - Cross-engine listeners for analytics integrations - More flexible event subscription options --- ANALYTICS_SERVICE.md | 68 ++++++++++++++++++++++++++++++++----- addon/services/analytics.js | 24 ++++++++----- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/ANALYTICS_SERVICE.md b/ANALYTICS_SERVICE.md index 7025bc0c..30504d78 100644 --- a/ANALYTICS_SERVICE.md +++ b/ANALYTICS_SERVICE.md @@ -14,23 +14,31 @@ The analytics service is designed to: ## Architecture +The analytics service implements a **dual event system**, firing events on both the analytics service itself and the universe service: + ``` Service/Component ↓ analytics.trackResourceCreated(order) ↓ -Analytics Service +Analytics Service (extends Evented) - Enriches with metadata - Formats payload - ↓ -universe.trigger('resource.created', ...) -universe.trigger('order.created', ...) - ↓ -Internals (or other engines) - - Listens via universe.on() - - Translates to PostHog/etc + - Fires on TWO event buses: + ↓ ↓ +analytics.trigger() universe.trigger() + ↓ ↓ +Local Listeners Cross-Engine Listeners +(same app/engine) (internals, other engines) ``` +### Dual Event System Benefits + +1. **Local listeners** - Components can listen directly on `analytics.on()` +2. **Cross-engine listeners** - Engines listen on `universe.on()` +3. **Flexible** - Choose the right event bus for your use case +4. **Follows patterns** - Like `current-user` service which also extends `Evented` + ## Installation The analytics service is automatically available in all engines and the console application. It's exported globally via `host-services` and `services`. @@ -345,7 +353,11 @@ export default class OrderFormComponent extends Component { } ``` -### Listening to Events (in Engines) +### Listening to Events + +#### Option 1: Listen on Universe (Cross-Engine) + +Use this approach in engines (like `internals`) to listen to events from other parts of the application: ```javascript // In an engine's instance-initializer @@ -368,6 +380,44 @@ export function initialize(owner) { } ``` +#### Option 2: Listen on Analytics Service (Local) + +Use this approach for local listeners within the same app/engine: + +```javascript +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class DashboardComponent extends Component { + @service analytics; + + constructor() { + super(...arguments); + + // Listen to order creation events locally + this.analytics.on('order.created', (order, properties) => { + console.log('Order created:', order.id); + this.refreshDashboard(); + }); + } + + willDestroy() { + super.willDestroy(); + // Clean up listeners + this.analytics.off('order.created'); + } +} +``` + +#### Which Event Bus to Use? + +| Use Case | Event Bus | Example | +|----------|-----------|----------| +| Cross-engine tracking (PostHog, etc.) | `universe` | Internals listening to core events | +| Local UI updates | `analytics` | Dashboard refreshing on data changes | +| Debugging/logging | `analytics` | Development tools | +| Testing | `analytics` | Unit tests mocking events | + ## Best Practices 1. **Use specific tracking methods** - Prefer `trackResourceCreated()` over `trackEvent()` diff --git a/addon/services/analytics.js b/addon/services/analytics.js index d52172e9..48b33305 100644 --- a/addon/services/analytics.js +++ b/addon/services/analytics.js @@ -1,4 +1,5 @@ import Service, { inject as service } from '@ember/service'; +import Evented from '@ember/object/evented'; import { getOwner } from '@ember/application'; import config from 'ember-get-config'; @@ -12,7 +13,7 @@ import config from 'ember-get-config'; * @class AnalyticsService * @extends Service */ -export default class AnalyticsService extends Service { +export default class AnalyticsService extends Service.extend(Evented) { @service universe; @service currentUser; @@ -190,7 +191,11 @@ export default class AnalyticsService extends Service { // ========================================================================= /** - * Triggers an event on the universe service + * Triggers an event on both the analytics service and universe service + * + * This dual event system allows listeners to subscribe to events on either: + * - this.analytics.on('event.name', handler) - Local listeners + * - this.universe.on('event.name', handler) - Cross-engine listeners * * @private * @param {String} eventName - The event name @@ -201,17 +206,20 @@ export default class AnalyticsService extends Service { return; } - if (!this.universe) { - console.warn('[Analytics] Universe service not available'); - return; - } - // Debug logging if enabled if (config?.analytics?.debug) { console.log(`[Analytics] ${eventName}`, args); } - this.universe.trigger(eventName, ...args); + // Trigger on analytics service (local listeners) + this.trigger(eventName, ...args); + + // Trigger on universe service (cross-engine listeners) + if (this.universe) { + this.universe.trigger(eventName, ...args); + } else { + console.warn('[Analytics] Universe service not available'); + } } /** From c54b9cdc425a0807d0448c299f6b97407a768a5a Mon Sep 17 00:00:00 2001 From: Manus AI Date: Tue, 10 Feb 2026 20:55:34 -0500 Subject: [PATCH 7/9] refactor: Rename analytics service to events service - Renamed service from 'analytics' to 'events' for better clarity - Updated class name from AnalyticsService to EventsService - Updated all service injections (@service events) - Updated all method calls (this.events.*) - Updated configuration namespace (config.events) - Renamed documentation file to EVENTS_SERVICE.md - Updated global exports in host-services and services Rationale: - 'events' is more accurate - it's an event emitter - More generic and flexible - not limited to analytics use cases - Can be used for analytics, logging, UI updates, webhooks, etc. - Clearer purpose and intent --- ANALYTICS_SERVICE.md => EVENTS_SERVICE.md | 68 +++++++++++----------- addon/exports/host-services.js | 2 +- addon/exports/services.js | 2 +- addon/services/crud.js | 10 ++-- addon/services/current-user.js | 6 +- addon/services/{analytics.js => events.js} | 34 +++++------ addon/services/resource-action.js | 12 ++-- app/services/analytics.js | 1 - app/services/events.js | 1 + 9 files changed, 68 insertions(+), 68 deletions(-) rename ANALYTICS_SERVICE.md => EVENTS_SERVICE.md (85%) rename addon/services/{analytics.js => events.js} (90%) delete mode 100644 app/services/analytics.js create mode 100644 app/services/events.js diff --git a/ANALYTICS_SERVICE.md b/EVENTS_SERVICE.md similarity index 85% rename from ANALYTICS_SERVICE.md rename to EVENTS_SERVICE.md index 30504d78..32969e27 100644 --- a/ANALYTICS_SERVICE.md +++ b/EVENTS_SERVICE.md @@ -1,10 +1,10 @@ -# Analytics Service +# Events Service The `analytics` service provides a centralized, analytics-agnostic event tracking system for Fleetbase. It emits standardized events via the `universe` service's event bus, allowing engines (like `internals`) to subscribe and implement their own analytics integrations. ## Overview -The analytics service is designed to: +The events service is designed to: - **Centralize event emission** - Single source of truth for all analytics events - **Remain analytics-agnostic** - No vendor-specific code (PostHog, Google Analytics, etc.) @@ -14,19 +14,19 @@ The analytics service is designed to: ## Architecture -The analytics service implements a **dual event system**, firing events on both the analytics service itself and the universe service: +The events service implements a **dual event system**, firing events on both the events service itself and the universe service: ``` Service/Component ↓ -analytics.trackResourceCreated(order) +events.trackResourceCreated(order) ↓ -Analytics Service (extends Evented) +Events Service (extends Evented) - Enriches with metadata - Formats payload - Fires on TWO event buses: ↓ ↓ -analytics.trigger() universe.trigger() +events.trigger() universe.trigger() ↓ ↓ Local Listeners Cross-Engine Listeners (same app/engine) (internals, other engines) @@ -34,14 +34,14 @@ Local Listeners Cross-Engine Listeners ### Dual Event System Benefits -1. **Local listeners** - Components can listen directly on `analytics.on()` +1. **Local listeners** - Components can listen directly on `events.on()` 2. **Cross-engine listeners** - Engines listen on `universe.on()` 3. **Flexible** - Choose the right event bus for your use case 4. **Follows patterns** - Like `current-user` service which also extends `Evented` ## Installation -The analytics service is automatically available in all engines and the console application. It's exported globally via `host-services` and `services`. +The events service is automatically available in all engines and the console application. It's exported globally via `host-services` and `services`. ### Injection @@ -49,11 +49,11 @@ The analytics service is automatically available in all engines and the console import { inject as service } from '@ember/service'; export default class MyService extends Service { - @service analytics; + @service events; async createOrder(orderData) { const order = await this.store.createRecord('order', orderData).save(); - this.analytics.trackResourceCreated(order); + this.events.trackResourceCreated(order); return order; } } @@ -77,7 +77,7 @@ Tracks the creation of a new resource. **Example:** ```javascript -this.analytics.trackResourceCreated(order); +this.events.trackResourceCreated(order); // Emits: resource.created, order.created ``` @@ -95,7 +95,7 @@ Tracks the update of an existing resource. **Example:** ```javascript -this.analytics.trackResourceUpdated(driver); +this.events.trackResourceUpdated(driver); // Emits: resource.updated, driver.updated ``` @@ -113,7 +113,7 @@ Tracks the deletion of a resource. **Example:** ```javascript -this.analytics.trackResourceDeleted(vehicle); +this.events.trackResourceDeleted(vehicle); // Emits: resource.deleted, vehicle.deleted ``` @@ -131,7 +131,7 @@ Tracks a bulk import of resources. **Example:** ```javascript -this.analytics.trackResourceImported('contact', 50); +this.events.trackResourceImported('contact', 50); // Emits: resource.imported with count: 50 ``` @@ -151,7 +151,7 @@ Tracks a resource export. **Example:** ```javascript -this.analytics.trackResourceExported('order', 'csv', { status: 'completed' }); +this.events.trackResourceExported('order', 'csv', { status: 'completed' }); // Emits: resource.exported, order.exported ``` @@ -169,7 +169,7 @@ Tracks a bulk action on multiple resources. **Example:** ```javascript -this.analytics.trackBulkAction('delete', selectedOrders); +this.events.trackBulkAction('delete', selectedOrders); // Emits: resource.bulk_action with count and action ``` @@ -189,7 +189,7 @@ Tracks when the current user is loaded (session initialized). **Example:** ```javascript -this.analytics.trackUserLoaded(user, organization); +this.events.trackUserLoaded(user, organization); // Emits: user.loaded ``` @@ -206,7 +206,7 @@ Tracks when a user session ends. **Example:** ```javascript -this.analytics.trackSessionTerminated(3600); +this.events.trackSessionTerminated(3600); // Emits: session.terminated with duration ``` @@ -225,7 +225,7 @@ Tracks a generic custom event. **Example:** ```javascript -this.analytics.trackEvent('chat.message.sent', { length: 140 }); +this.events.trackEvent('chat.message.sent', { length: 140 }); // Emits: chat.message.sent ``` @@ -239,14 +239,14 @@ Checks if analytics tracking is enabled. **Example:** ```javascript -if (this.analytics.isEnabled()) { +if (this.events.isEnabled()) { // Tracking is enabled } ``` ## Configuration -The analytics service can be configured via `config/environment.js`: +The events service can be configured via `config/environment.js`: ```javascript // config/environment.js @@ -305,14 +305,14 @@ import Service, { inject as service } from '@ember/service'; export default class OrderService extends Service { @service store; - @service analytics; + @service events; async createOrder(orderData) { const order = this.store.createRecord('order', orderData); await order.save(); // Track the creation - this.analytics.trackResourceCreated(order); + this.events.trackResourceCreated(order); return order; } @@ -322,7 +322,7 @@ export default class OrderService extends Service { await order.save(); // Track the update - this.analytics.trackResourceUpdated(order); + this.events.trackResourceUpdated(order); return order; } @@ -337,7 +337,7 @@ import { inject as service } from '@ember/service'; import { action } from '@ember/object'; export default class OrderFormComponent extends Component { - @service analytics; + @service events; @action async saveOrder() { @@ -345,9 +345,9 @@ export default class OrderFormComponent extends Component { // Track the save if (this.args.order.isNew) { - this.analytics.trackResourceCreated(this.args.order); + this.events.trackResourceCreated(this.args.order); } else { - this.analytics.trackResourceUpdated(this.args.order); + this.events.trackResourceUpdated(this.args.order); } } } @@ -380,7 +380,7 @@ export function initialize(owner) { } ``` -#### Option 2: Listen on Analytics Service (Local) +#### Option 2: Listen on Events Service (Local) Use this approach for local listeners within the same app/engine: @@ -389,13 +389,13 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; export default class DashboardComponent extends Component { - @service analytics; + @service events; constructor() { super(...arguments); // Listen to order creation events locally - this.analytics.on('order.created', (order, properties) => { + this.events.on('order.created', (order, properties) => { console.log('Order created:', order.id); this.refreshDashboard(); }); @@ -404,7 +404,7 @@ export default class DashboardComponent extends Component { willDestroy() { super.willDestroy(); // Clean up listeners - this.analytics.off('order.created'); + this.events.off('order.created'); } } ``` @@ -429,7 +429,7 @@ export default class DashboardComponent extends Component { ## Testing -To stub the analytics service in tests: +To stub the events service in tests: ```javascript import { module, test } from 'qunit'; @@ -466,10 +466,10 @@ this.universe.trigger('order.created', model); **After:** ```javascript -this.analytics.trackResourceCreated(model); +this.events.trackResourceCreated(model); // Automatically emits both events ``` ## Support -For questions or issues with the analytics service, please refer to the Fleetbase documentation or contact the development team. +For questions or issues with the events service, please refer to the Fleetbase documentation or contact the development team. diff --git a/addon/exports/host-services.js b/addon/exports/host-services.js index 91830230..f900dd2e 100644 --- a/addon/exports/host-services.js +++ b/addon/exports/host-services.js @@ -19,7 +19,7 @@ export const hostServices = [ 'sidebar', 'dashboard', 'universe', - 'analytics', + 'events', 'intl', 'abilities', 'language', diff --git a/addon/exports/services.js b/addon/exports/services.js index 79fa36c5..b51f3d21 100644 --- a/addon/exports/services.js +++ b/addon/exports/services.js @@ -21,7 +21,7 @@ export const services = [ 'sidebar', 'dashboard', 'universe', - 'analytics', + 'events', 'intl', 'abilities', 'language', diff --git a/addon/services/crud.js b/addon/services/crud.js index 53a8e24d..eb6d0bc3 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -18,7 +18,7 @@ export default class CrudService extends Service { @service store; @service currentUser; @service universe; - @service analytics; + @service events; /** * Generic deletion modal with options @@ -47,7 +47,7 @@ export default class CrudService extends Service { this.notifications.success(successNotification); // Track deletion event - this.analytics.trackResourceDeleted(model); + this.events.trackResourceDeleted(model); if (typeof options.onSuccess === 'function') { options.onSuccess(model); @@ -169,7 +169,7 @@ export default class CrudService extends Service { this.notifications.success(response.message ?? successMessage); // Track bulk action event - this.analytics.trackBulkAction(verb, selected); + this.events.trackBulkAction(verb, selected); if (typeof options.onSuccess === 'function') { options.onSuccess(selected); @@ -235,7 +235,7 @@ export default class CrudService extends Service { ) .then(() => { // Track export event - this.analytics.trackResourceExported(modelName, format, exportParams); + this.events.trackResourceExported(modelName, format, exportParams); later( this, @@ -355,7 +355,7 @@ export default class CrudService extends Service { // Track import event const importCount = response?.imported?.length || response?.count || files.length; - this.analytics.trackResourceImported(modelName, importCount); + this.events.trackResourceImported(modelName, importCount); if (typeof options.onImportCompleted === 'function') { options.onImportCompleted(response, files); diff --git a/addon/services/current-user.js b/addon/services/current-user.js index 2db2930d..f98a7d84 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -16,7 +16,7 @@ export default class CurrentUserService extends Service.extend(Evented) { @service theme; @service notifications; @service intl; - @service analytics; + @service events; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; @@ -73,7 +73,7 @@ export default class CurrentUserService extends Service.extend(Evented) { this.trigger('user.loaded', user); // Track user loaded event - this.analytics.trackUserLoaded(user, user.company); + this.events.trackUserLoaded(user, user.company); // Set permissions this.permissions = this.getUserPermissions(user); @@ -103,7 +103,7 @@ export default class CurrentUserService extends Service.extend(Evented) { this.trigger('user.loaded', user); // Track user loaded event - this.analytics.trackUserLoaded(user, user.company); + this.events.trackUserLoaded(user, user.company); // Set permissions this.permissions = this.getUserPermissions(user); diff --git a/addon/services/analytics.js b/addon/services/events.js similarity index 90% rename from addon/services/analytics.js rename to addon/services/events.js index 48b33305..8c8cb017 100644 --- a/addon/services/analytics.js +++ b/addon/services/events.js @@ -4,16 +4,16 @@ import { getOwner } from '@ember/application'; import config from 'ember-get-config'; /** - * Analytics Service + * Events Service * - * Provides a centralized, analytics-agnostic event tracking system for Fleetbase. - * This service emits standardized events via the universe service's event bus, - * allowing engines (like internals) to subscribe and implement their own analytics. + * Provides a centralized event tracking system for Fleetbase. + * This service emits standardized events on both its own event bus and the universe service, + * allowing components, services, and engines to subscribe and react to application events. * - * @class AnalyticsService + * @class EventsService * @extends Service */ -export default class AnalyticsService extends Service.extend(Evented) { +export default class EventsService extends Service.extend(Evented) { @service universe; @service currentUser; @@ -177,13 +177,13 @@ export default class AnalyticsService extends Service.extend(Evented) { } /** - * Checks if analytics tracking is enabled + * Checks if event tracking is enabled * * @returns {Boolean} */ isEnabled() { - const analyticsConfig = config?.analytics || {}; - return analyticsConfig.enabled !== false; // Enabled by default + const eventsConfig = config?.events || {}; + return eventsConfig.enabled !== false; // Enabled by default } // ========================================================================= @@ -191,10 +191,10 @@ export default class AnalyticsService extends Service.extend(Evented) { // ========================================================================= /** - * Triggers an event on both the analytics service and universe service + * Triggers an event on both the events service and universe service * * This dual event system allows listeners to subscribe to events on either: - * - this.analytics.on('event.name', handler) - Local listeners + * - this.events.on('event.name', handler) - Local listeners * - this.universe.on('event.name', handler) - Cross-engine listeners * * @private @@ -207,18 +207,18 @@ export default class AnalyticsService extends Service.extend(Evented) { } // Debug logging if enabled - if (config?.analytics?.debug) { - console.log(`[Analytics] ${eventName}`, args); + if (config?.events?.debug) { + console.log(`[Events] ${eventName}`, args); } - // Trigger on analytics service (local listeners) + // Trigger on events service (local listeners) this.trigger(eventName, ...args); // Trigger on universe service (cross-engine listeners) if (this.universe) { this.universe.trigger(eventName, ...args); } else { - console.warn('[Analytics] Universe service not available'); + console.warn('[Events] Universe service not available'); } } @@ -274,8 +274,8 @@ export default class AnalyticsService extends Service.extend(Evented) { * @returns {Object} Enriched properties */ #enrichProperties(props = {}) { - const analyticsConfig = config?.analytics || {}; - const enrichConfig = analyticsConfig.enrich || {}; + const eventsConfig = config?.events || {}; + const enrichConfig = eventsConfig.enrich || {}; const enriched = { ...props }; // Add user context if enabled diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js index 62999a55..7df7ac18 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -30,7 +30,7 @@ export default class ResourceActionService extends Service { @service tableContext; @service resourceContextPanel; @service universe; - @service analytics; + @service events; /** * Getter for router, attempt to use hostRouter if from engine @@ -302,7 +302,7 @@ export default class ResourceActionService extends Service { ); // Track creation event - this.analytics.trackResourceCreated(record); + this.events.trackResourceCreated(record); if (options.refresh) { this.refresh(); @@ -335,7 +335,7 @@ export default class ResourceActionService extends Service { ); // Track update event - this.analytics.trackResourceUpdated(record); + this.events.trackResourceUpdated(record); if (options.refresh) { this.refresh(); @@ -372,9 +372,9 @@ export default class ResourceActionService extends Service { // Track save event (create or update) if (isNew) { - this.analytics.trackResourceCreated(record); + this.events.trackResourceCreated(record); } else { - this.analytics.trackResourceUpdated(record); + this.events.trackResourceUpdated(record); } if (options.refresh) { @@ -425,7 +425,7 @@ export default class ResourceActionService extends Service { ); // Track deletion event - this.analytics.trackResourceDeleted(record); + this.events.trackResourceDeleted(record); if (options.refresh) { this.refresh(); diff --git a/app/services/analytics.js b/app/services/analytics.js deleted file mode 100644 index 37069d48..00000000 --- a/app/services/analytics.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/ember-core/services/analytics'; diff --git a/app/services/events.js b/app/services/events.js new file mode 100644 index 00000000..c73e6c67 --- /dev/null +++ b/app/services/events.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/services/events'; From 356955744519171f72fc903d0b5a057732bbb98c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 11 Feb 2026 17:36:04 +0800 Subject: [PATCH 8/9] remove old markdown files --- BOOT_SEQUENCE_REFACTOR_GUIDE.md | 294 ----------------- EVENTS_SERVICE.md | 475 --------------------------- UNIVERSE_REFACTOR_MIGRATION_GUIDE.md | 318 ------------------ UNIVERSE_REFACTOR_README.md | 220 ------------- 4 files changed, 1307 deletions(-) delete mode 100644 BOOT_SEQUENCE_REFACTOR_GUIDE.md delete mode 100644 EVENTS_SERVICE.md delete mode 100644 UNIVERSE_REFACTOR_MIGRATION_GUIDE.md delete mode 100644 UNIVERSE_REFACTOR_README.md diff --git a/BOOT_SEQUENCE_REFACTOR_GUIDE.md b/BOOT_SEQUENCE_REFACTOR_GUIDE.md deleted file mode 100644 index afa47052..00000000 --- a/BOOT_SEQUENCE_REFACTOR_GUIDE.md +++ /dev/null @@ -1,294 +0,0 @@ -# Boot Sequence Refactor Guide - -## Overview - -This guide provides the steps to refactor the application boot sequence to enable true lazy loading and move away from the old `bootEngines` mechanism that loads all extensions at startup. - -## Understanding the Extension Loading Flow - -The Fleetbase application has a three-tier extension loading system: - -1. **pnpm Installation**: All extensions are installed via pnpm, making them available to the application -2. **System Configuration**: Extensions defined in `fleetbase.config.js` or `EXTENSIONS` environment variable are loaded globally -3. **User Permissions**: Individual users can install/uninstall extensions, which affects what loads for them specifically - -Only extensions that are both installed AND enabled (via config or user permissions) will be initialized. - -## The Goal - -Stop loading all extension code at boot time. Instead: -- Load only the `extension.js` files (metadata registration) -- Keep engine bundles lazy-loaded (loaded on-demand when routes are visited) -- Preserve the `engines` property required by ember-engines for lazy loading - -## Key Changes - -1. **Keep `app.engines` property**: Required by ember-engines for lazy loading -2. **Create new `initialize-universe` instance initializer**: Loads `extension.js` files and registers metadata -3. **Remove `bootEngines` calls**: No more manual engine booting at startup - -## Step-by-Step Guide - -### Step 1: Update `app.js` to Preserve Engines Property - -The `engines` property is **required** by ember-engines to enable lazy loading. Keep the existing structure but remove any `bootEngines` calls. - -**Current `app.js` (fleetbase/console/app/app.js):** - -```javascript -import Application from '@ember/application'; -import Resolver from 'ember-resolver'; -import loadInitializers from 'ember-load-initializers'; -import config from '@fleetbase/console/config/environment'; -import loadExtensions from '@fleetbase/ember-core/utils/load-extensions'; -import mapEngines from '@fleetbase/ember-core/utils/map-engines'; -import loadRuntimeConfig from '@fleetbase/console/utils/runtime-config'; -import applyRouterFix from './utils/router-refresh-patch'; - -export default class App extends Application { - modulePrefix = config.modulePrefix; - podModulePrefix = config.podModulePrefix; - Resolver = Resolver; - extensions = []; - engines = {}; // ← KEEP THIS! Required by ember-engines - - async ready() { - applyRouterFix(this); - const extensions = await loadExtensions(); - - this.extensions = extensions; - this.engines = mapEngines(extensions); // ← KEEP THIS! Maps extensions to engines - } -} - -document.addEventListener('DOMContentLoaded', async () => { - await loadRuntimeConfig(); - loadInitializers(App, config.modulePrefix); - - let fleetbase = App.create(); - fleetbase.deferReadiness(); - fleetbase.boot(); -}); -``` - -**What to Keep:** -- ✅ `extensions` property - tracks which extensions are enabled -- ✅ `engines` property - required by ember-engines for lazy loading -- ✅ `loadExtensions()` - determines which extensions to load based on config + user permissions -- ✅ `mapEngines()` - creates the engines object required by ember-engines - -**What Changes:** -- ❌ Remove any `bootEngines()` calls (if present in instance initializers) -- ❌ Remove `initialize-widgets.js` instance initializer (logic moves to `extension.js`) - -### Step 2: Remove Old Instance Initializers - -Delete the following instance initializers that perform eager engine loading: - -**Files to Delete:** -- `app/instance-initializers/load-extensions.js` (if it calls `bootEngines`) -- `app/instance-initializers/initialize-widgets.js` (widgets now registered via `extension.js`) - -### Step 3: Create New `initialize-universe` Initializer - -Create a new instance initializer at `app/instance-initializers/initialize-universe.js`: - -```javascript -import { getOwner } from '@ember/application'; -import { scheduleOnce } from '@ember/runloop'; - -/** - * Initializes the Universe by loading and executing extension.js files - * from all enabled extensions. This replaces the old bootEngines mechanism. - * - * Key differences from old approach: - * - Only loads extension.js files (small, metadata only) - * - Does NOT load engine bundles (those lazy-load when routes are visited) - * - Respects both system config and user permissions - * - * @param {ApplicationInstance} appInstance The application instance - */ -export function initialize(appInstance) { - const universe = appInstance.lookup('service:universe'); - const owner = getOwner(appInstance); - const app = owner.application; - - // Set application instance on universe - universe.applicationInstance = appInstance; - - // Get the list of enabled extensions from the app - // This list already respects config + user permissions via loadExtensions() - const extensions = app.extensions || []; - - // Load and execute extension.js from each enabled extension - extensions.forEach(extensionName => { - try { - // Dynamically require the extension.js file - // This is a small file with only metadata, not the full engine bundle - const setupExtension = require(`${extensionName}/extension`).default; - - if (typeof setupExtension === 'function') { - // Execute the extension setup function - // This registers menus, widgets, hooks, etc. as metadata - setupExtension(appInstance, universe); - } - } catch (error) { - // Silently fail if extension.js doesn't exist - // Extensions can migrate gradually to the new pattern - // console.warn(`Could not load extension.js for ${extensionName}:`, error); - } - }); - - // Execute any boot callbacks - scheduleOnce('afterRender', universe, 'executeBootCallbacks'); -} - -export default { - name: 'initialize-universe', - initialize -}; -``` - -### Step 4: Verify `router.js` Engine Mounting - -Your `prebuild.js` script already handles mounting engines in `router.js`. Verify that engines are mounted like this: - -```javascript -// This is generated by prebuild.js -this.mount('@fleetbase/fleetops-engine', { as: 'console.fleet-ops' }); -this.mount('@fleetbase/customer-portal-engine', { as: 'console.customer-portal' }); -``` - -**Important**: The `this.mount()` calls are what enable ember-engines lazy loading. When a user navigates to a route, ember-engines automatically loads the engine bundle on-demand. - -### Step 5: Migrate Extensions to `extension.js` Pattern - -For each extension, create an `addon/extension.js` file that registers metadata without importing components: - -**Example: FleetOps `addon/extension.js`** - -```javascript -import { MenuItem, MenuPanel, Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -export default function (app, universe) { - // Register admin menu panel - universe.registerAdminMenuPanel( - 'Fleet-Ops', - new MenuPanel({ - title: 'Fleet-Ops', - icon: 'route', - items: [ - new MenuItem({ - title: 'Navigator App', - icon: 'location-arrow', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app') - }), - new MenuItem({ - title: 'Avatar Management', - icon: 'images', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/avatar-management') - }) - ] - }) - ); - - // Register widgets - universe.registerDefaultWidget( - new Widget({ - widgetId: 'fleet-ops-metrics', - name: 'Fleet-Ops Metrics', - description: 'Key metrics from Fleet-Ops', - icon: 'truck', - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics'), - grid_options: { w: 12, h: 12, minW: 8, minH: 12 } - }) - ); - - // Register hooks - universe.registerHook( - new Hook({ - name: 'application:before-model', - handler: (session, router) => { - // Custom logic here - }, - priority: 10 - }) - ); -} -``` - -**Key Points:** -- ❌ NO `import MyComponent from './components/my-component'` - this would load the engine! -- ✅ Use `ExtensionComponent` with engine name + path for lazy loading -- ✅ Use contract classes (`MenuItem`, `Widget`, `Hook`) for type safety - -See [UNIVERSE_REFACTOR_MIGRATION_GUIDE.md](./UNIVERSE_REFACTOR_MIGRATION_GUIDE.md) for detailed migration examples. - -## How Lazy Loading Works with This Approach - -1. **App Boot**: Application boots with `app.engines` property set -2. **`initialize-universe`**: Loads small `extension.js` files via `require()` -3. **Metadata Registration**: Extensions register menus, widgets, hooks (no component code loaded) -4. **User Navigation**: User navigates to `/console/fleet-ops` -5. **Ember-Engines**: Detects route is in a mounted engine, lazy-loads the engine bundle -6. **Component Resolution**: `` resolves components from loaded engine - -## Performance Impact - -| Metric | Before (bootEngines) | After (Lazy Loading) | -|--------|---------------------|---------------------| -| Initial Load Time | 10-40 seconds | <1 second | -| Initial Bundle Size | Core + All Engines | Core + extension.js files | -| Engine Loading | All at boot | On-demand when route visited | -| Memory Usage | All engines in memory | Only visited engines in memory | - -## Ember-Engines Requirements - -According to [ember-engines documentation](https://github.com/ember-engines/ember-engines): - -> **Lazy loading** - An engine can allow its parent to boot with only its routing map loaded. The rest of the engine can be loaded only as required (i.e. when a route in an engine is visited). This allows applications to boot faster and limit their memory consumption. - -**Required for lazy loading:** -1. ✅ `app.engines` property must be set (maps extension names to engine modules) -2. ✅ Engines must be mounted in `router.js` via `this.mount()` -3. ✅ Engine's `index.js` must have `lazyLoading: true` (default) - -**What breaks lazy loading:** -1. ❌ Calling `owner.lookup('engine:my-engine')` at boot time -2. ❌ Importing components from engines in `extension.js` -3. ❌ Manual `bootEngines()` calls - -## Troubleshooting - -### Extension not loading -- Check that extension is in `app.extensions` array -- Verify `extension.js` file exists and exports a function -- Check browser console for errors - -### Components not rendering -- Ensure `ExtensionComponent` has correct engine name and path -- Verify engine is mounted in `router.js` -- Check that `` is used in templates - -### Engines loading at boot -- Remove any `owner.lookup('engine:...')` calls from initializers -- Remove component imports from `extension.js` -- Verify no `bootEngines()` calls remain - -## Migration Checklist - -- [ ] Update `app.js` to keep `engines` property -- [ ] Remove old instance initializers (`load-extensions.js`, `initialize-widgets.js`) -- [ ] Create new `initialize-universe.js` instance initializer -- [ ] Verify `router.js` has `this.mount()` calls for all engines -- [ ] Create `extension.js` for each extension -- [ ] Replace component imports with `ExtensionComponent` definitions -- [ ] Test lazy loading in browser dev tools (Network tab) -- [ ] Verify initial bundle size reduction -- [ ] Test all extension functionality still works - -## References - -- [Ember Engines Guide](https://guides.emberjs.com/v5.6.0/applications/ember-engines/) -- [ember-engines GitHub](https://github.com/ember-engines/ember-engines) -- [Ember Engines RFC](https://github.com/emberjs/rfcs/blob/master/text/0010-engines.md) diff --git a/EVENTS_SERVICE.md b/EVENTS_SERVICE.md deleted file mode 100644 index 32969e27..00000000 --- a/EVENTS_SERVICE.md +++ /dev/null @@ -1,475 +0,0 @@ -# Events Service - -The `analytics` service provides a centralized, analytics-agnostic event tracking system for Fleetbase. It emits standardized events via the `universe` service's event bus, allowing engines (like `internals`) to subscribe and implement their own analytics integrations. - -## Overview - -The events service is designed to: - -- **Centralize event emission** - Single source of truth for all analytics events -- **Remain analytics-agnostic** - No vendor-specific code (PostHog, Google Analytics, etc.) -- **Use the universe event bus** - Events are published via `universe.trigger()` -- **Be opt-in** - Services and components must explicitly call tracking methods -- **Enrich events automatically** - Adds user, organization, and timestamp context - -## Architecture - -The events service implements a **dual event system**, firing events on both the events service itself and the universe service: - -``` -Service/Component - ↓ -events.trackResourceCreated(order) - ↓ -Events Service (extends Evented) - - Enriches with metadata - - Formats payload - - Fires on TWO event buses: - ↓ ↓ -events.trigger() universe.trigger() - ↓ ↓ -Local Listeners Cross-Engine Listeners -(same app/engine) (internals, other engines) -``` - -### Dual Event System Benefits - -1. **Local listeners** - Components can listen directly on `events.on()` -2. **Cross-engine listeners** - Engines listen on `universe.on()` -3. **Flexible** - Choose the right event bus for your use case -4. **Follows patterns** - Like `current-user` service which also extends `Evented` - -## Installation - -The events service is automatically available in all engines and the console application. It's exported globally via `host-services` and `services`. - -### Injection - -```javascript -import { inject as service } from '@ember/service'; - -export default class MyService extends Service { - @service events; - - async createOrder(orderData) { - const order = await this.store.createRecord('order', orderData).save(); - this.events.trackResourceCreated(order); - return order; - } -} -``` - -## API Reference - -### Resource Tracking - -#### `trackResourceCreated(resource, props = {})` - -Tracks the creation of a new resource. - -**Parameters:** -- `resource` (Object) - The created Ember Data model -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `resource.created` (generic) -- `{modelName}.created` (specific, e.g., `order.created`) - -**Example:** -```javascript -this.events.trackResourceCreated(order); -// Emits: resource.created, order.created -``` - -#### `trackResourceUpdated(resource, props = {})` - -Tracks the update of an existing resource. - -**Parameters:** -- `resource` (Object) - The updated Ember Data model -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `resource.updated` (generic) -- `{modelName}.updated` (specific, e.g., `driver.updated`) - -**Example:** -```javascript -this.events.trackResourceUpdated(driver); -// Emits: resource.updated, driver.updated -``` - -#### `trackResourceDeleted(resource, props = {})` - -Tracks the deletion of a resource. - -**Parameters:** -- `resource` (Object) - The deleted Ember Data model -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `resource.deleted` (generic) -- `{modelName}.deleted` (specific, e.g., `vehicle.deleted`) - -**Example:** -```javascript -this.events.trackResourceDeleted(vehicle); -// Emits: resource.deleted, vehicle.deleted -``` - -#### `trackResourceImported(modelName, count, props = {})` - -Tracks a bulk import of resources. - -**Parameters:** -- `modelName` (String) - The name of the model being imported -- `count` (Number) - Number of resources imported -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `resource.imported` - -**Example:** -```javascript -this.events.trackResourceImported('contact', 50); -// Emits: resource.imported with count: 50 -``` - -#### `trackResourceExported(modelName, format, params = {}, props = {})` - -Tracks a resource export. - -**Parameters:** -- `modelName` (String) - The name of the model being exported -- `format` (String) - Export format (csv, xlsx, pdf, etc.) -- `params` (Object, optional) - Export parameters/filters -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `resource.exported` (generic) -- `{modelName}.exported` (specific, e.g., `order.exported`) - -**Example:** -```javascript -this.events.trackResourceExported('order', 'csv', { status: 'completed' }); -// Emits: resource.exported, order.exported -``` - -#### `trackBulkAction(verb, resources, props = {})` - -Tracks a bulk action on multiple resources. - -**Parameters:** -- `verb` (String) - The action verb (delete, archive, etc.) -- `resources` (Array) - Array of selected resources -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `resource.bulk_action` - -**Example:** -```javascript -this.events.trackBulkAction('delete', selectedOrders); -// Emits: resource.bulk_action with count and action -``` - -### Session Tracking - -#### `trackUserLoaded(user, organization, props = {})` - -Tracks when the current user is loaded (session initialized). - -**Parameters:** -- `user` (Object) - The user object -- `organization` (Object) - The organization object -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `user.loaded` - -**Example:** -```javascript -this.events.trackUserLoaded(user, organization); -// Emits: user.loaded -``` - -#### `trackSessionTerminated(duration, props = {})` - -Tracks when a user session ends. - -**Parameters:** -- `duration` (Number) - Session duration in seconds -- `props` (Object, optional) - Additional properties to include - -**Events Emitted:** -- `session.terminated` - -**Example:** -```javascript -this.events.trackSessionTerminated(3600); -// Emits: session.terminated with duration -``` - -### Custom Events - -#### `trackEvent(eventName, props = {})` - -Tracks a generic custom event. - -**Parameters:** -- `eventName` (String) - The event name (dot notation recommended) -- `props` (Object, optional) - Event properties - -**Events Emitted:** -- `{eventName}` (as specified) - -**Example:** -```javascript -this.events.trackEvent('chat.message.sent', { length: 140 }); -// Emits: chat.message.sent -``` - -### Utility Methods - -#### `isEnabled()` - -Checks if analytics tracking is enabled. - -**Returns:** `Boolean` - -**Example:** -```javascript -if (this.events.isEnabled()) { - // Tracking is enabled -} -``` - -## Configuration - -The events service can be configured via `config/environment.js`: - -```javascript -// config/environment.js -ENV.analytics = { - enabled: true, // Master switch (default: true) - debug: false, // Log events to console (default: false) - enrich: { - user: true, // Add user_id to events (default: true) - organization: true, // Add organization_id to events (default: true) - timestamp: true // Add timestamp to events (default: true) - } -}; -``` - -### Configuration Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | Boolean | `true` | Master switch to enable/disable all tracking | -| `debug` | Boolean | `false` | Log events to console for debugging | -| `enrich.user` | Boolean | `true` | Add `user_id` to all events | -| `enrich.organization` | Boolean | `true` | Add `organization_id` to all events | -| `enrich.timestamp` | Boolean | `true` | Add `timestamp` to all events | - -## Event Naming Convention - -All events use **dot notation** with the following patterns: - -- **Generic resource events:** `resource.{action}` (e.g., `resource.created`) -- **Specific resource events:** `{modelName}.{action}` (e.g., `order.created`) -- **Session events:** `user.loaded`, `session.terminated` -- **Custom events:** Use dot notation (e.g., `chat.message.sent`) - -## Event Properties - -All events are automatically enriched with the following properties (if enabled): - -- `user_id` - Current user's ID -- `organization_id` - Current organization's ID -- `timestamp` - ISO 8601 timestamp - -Resource events also include: - -- `id` - Resource ID -- `model_name` - Model name -- `name` - Resource name (if available) -- `status` - Resource status (if available) -- `type` - Resource type (if available) - -## Usage Examples - -### In a Service - -```javascript -import Service, { inject as service } from '@ember/service'; - -export default class OrderService extends Service { - @service store; - @service events; - - async createOrder(orderData) { - const order = this.store.createRecord('order', orderData); - await order.save(); - - // Track the creation - this.events.trackResourceCreated(order); - - return order; - } - - async updateOrder(order, updates) { - order.setProperties(updates); - await order.save(); - - // Track the update - this.events.trackResourceUpdated(order); - - return order; - } -} -``` - -### In a Component - -```javascript -import Component from '@glimmer/component'; -import { inject as service } from '@ember/service'; -import { action } from '@ember/object'; - -export default class OrderFormComponent extends Component { - @service events; - - @action - async saveOrder() { - await this.args.order.save(); - - // Track the save - if (this.args.order.isNew) { - this.events.trackResourceCreated(this.args.order); - } else { - this.events.trackResourceUpdated(this.args.order); - } - } -} -``` - -### Listening to Events - -#### Option 1: Listen on Universe (Cross-Engine) - -Use this approach in engines (like `internals`) to listen to events from other parts of the application: - -```javascript -// In an engine's instance-initializer -export function initialize(owner) { - const universe = owner.lookup('service:universe'); - const posthog = owner.lookup('service:posthog'); - - // Listen to order creation events - universe.on('order.created', (order, properties) => { - posthog.trackEvent('order_created', { - order_id: order.id, - ...properties - }); - }); - - // Listen to all resource creation events - universe.on('resource.created', (resource, properties) => { - posthog.trackEvent('resource_created', properties); - }); -} -``` - -#### Option 2: Listen on Events Service (Local) - -Use this approach for local listeners within the same app/engine: - -```javascript -import Component from '@glimmer/component'; -import { inject as service } from '@ember/service'; - -export default class DashboardComponent extends Component { - @service events; - - constructor() { - super(...arguments); - - // Listen to order creation events locally - this.events.on('order.created', (order, properties) => { - console.log('Order created:', order.id); - this.refreshDashboard(); - }); - } - - willDestroy() { - super.willDestroy(); - // Clean up listeners - this.events.off('order.created'); - } -} -``` - -#### Which Event Bus to Use? - -| Use Case | Event Bus | Example | -|----------|-----------|----------| -| Cross-engine tracking (PostHog, etc.) | `universe` | Internals listening to core events | -| Local UI updates | `analytics` | Dashboard refreshing on data changes | -| Debugging/logging | `analytics` | Development tools | -| Testing | `analytics` | Unit tests mocking events | - -## Best Practices - -1. **Use specific tracking methods** - Prefer `trackResourceCreated()` over `trackEvent()` -2. **Track after success** - Only track events after the operation succeeds -3. **Keep properties minimal** - Only include necessary data -4. **Use dot notation** - For custom event names (e.g., `chat.message.sent`) -5. **Don't track sensitive data** - Avoid passwords, tokens, payment info -6. **Test with debug mode** - Set `analytics.debug: true` in development - -## Testing - -To stub the events service in tests: - -```javascript -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; -import sinon from 'sinon'; - -module('Unit | Service | order', function(hooks) { - setupTest(hooks); - - test('it tracks order creation', async function(assert) { - const service = this.owner.lookup('service:order'); - const analytics = this.owner.lookup('service:analytics'); - - // Stub the tracking method - const trackStub = sinon.stub(analytics, 'trackResourceCreated'); - - await service.createOrder({ name: 'Test Order' }); - - // Assert tracking was called - assert.ok(trackStub.calledOnce); - }); -}); -``` - -## Migration from Direct universe.trigger() - -If you previously used `universe.trigger()` directly: - -**Before:** -```javascript -this.universe.trigger('resource.created', model); -this.universe.trigger('order.created', model); -``` - -**After:** -```javascript -this.events.trackResourceCreated(model); -// Automatically emits both events -``` - -## Support - -For questions or issues with the events service, please refer to the Fleetbase documentation or contact the development team. diff --git a/UNIVERSE_REFACTOR_MIGRATION_GUIDE.md b/UNIVERSE_REFACTOR_MIGRATION_GUIDE.md deleted file mode 100644 index c47756fd..00000000 --- a/UNIVERSE_REFACTOR_MIGRATION_GUIDE.md +++ /dev/null @@ -1,318 +0,0 @@ -# UniverseService Refactor Migration Guide - -## Overview - -The UniverseService has been completely refactored to improve performance, maintainability, and developer experience. This guide will help you migrate your extensions to the new architecture. - -## What Changed? - -### 1. Service Decomposition - -The monolithic `UniverseService` has been split into specialized services: - -- **ExtensionManager**: Manages lazy loading of engines -- **RegistryService**: Manages all registries using Ember's container -- **MenuService**: Manages menu items and panels -- **WidgetService**: Manages dashboard widgets -- **HookService**: Manages application hooks - -The original `UniverseService` now acts as a facade, delegating to these services while maintaining backward compatibility. - -### 2. Contract System - -New contract classes provide a fluent, type-safe API: - -- `ExtensionComponent`: Lazy-loadable component definitions -- `MenuItem`: Menu item definitions -- `MenuPanel`: Menu panel definitions -- `Hook`: Hook definitions -- `Widget`: Widget definitions -- `Registry`: Registry namespace definitions - -### 3. Lazy Loading Architecture - -The old `bootEngines` mechanism has been replaced with on-demand lazy loading: - -- Engines are no longer loaded at boot time -- Components are loaded only when needed -- The `` wrapper handles lazy loading automatically - -## Migration Steps - -### Step 1: Create `extension.js` File - -Each engine should create a new `addon/extension.js` file to replace the `setupExtension` method in `engine.js`. - -**Before (`addon/engine.js`):** - -```javascript -import NavigatorAppComponent from './components/admin/navigator-app'; - -export default class FleetOpsEngine extends Engine { - setupExtension = function (app, engine, universe) { - universe.registerHeaderMenuItem('Fleet-Ops', 'console.fleet-ops', { - icon: 'route', - priority: 0 - }); - - universe.registerAdminMenuPanel('Fleet-Ops Config', [ - { - title: 'Navigator App', - component: NavigatorAppComponent - } - ]); - }; -} -``` - -**After (`addon/extension.js`):** - -```javascript -import { MenuItem, MenuPanel, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -export default function (app, universe) { - // Register header menu item - universe.registerHeaderMenuItem( - new MenuItem('Fleet-Ops', 'console.fleet-ops') - .withIcon('route') - .withPriority(0) - ); - - // Register admin panel with lazy component - universe.registerAdminMenuPanel( - new MenuPanel('Fleet-Ops Config') - .addItem( - new MenuItem('Navigator App') - .withIcon('location-arrow') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app') - ) - ) - ); -} -``` - -**After (`addon/engine.js`):** - -```javascript -// Remove the setupExtension method entirely -export default class FleetOpsEngine extends Engine { - // ... other engine configuration -} -``` - -### Step 2: Use Contract Classes - -Instead of plain objects, use the new contract classes for better type safety and developer experience. - -**Before:** - -```javascript -universe.registerWidget({ - widgetId: 'fleet-ops-metrics', - name: 'Fleet-Ops Metrics', - icon: 'truck', - component: WidgetComponent, - grid_options: { w: 12, h: 12 } -}); -``` - -**After:** - -```javascript -import { Widget, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -universe.registerDashboardWidgets([ - new Widget('fleet-ops-metrics') - .withName('Fleet-Ops Metrics') - .withIcon('truck') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/fleet-ops-key-metrics') - ) - .withGridOptions({ w: 12, h: 12 }) -]); -``` - -### Step 3: Update Component References - -Replace direct component imports with lazy component definitions. - -**Before:** - -```javascript -import MyComponent from './components/my-component'; - -universe.registerMenuItem('my-registry', 'My Item', { - component: MyComponent -}); -``` - -**After:** - -```javascript -import { MenuItem, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -universe.registerMenuItem( - 'my-registry', - new MenuItem('My Item') - .withComponent( - new ExtensionComponent('@fleetbase/my-engine', 'components/my-component') - ) -); -``` - -### Step 4: Update Templates Using Registry Components - -Templates that render components from registries need to use the `` wrapper. - -**Before:** - -```handlebars -{{#each this.menuItems as |item|}} - {{component item.component model=@model}} -{{/each}} -``` - -**After:** - -```handlebars -{{#each this.menuItems as |item|}} - -{{/each}} -``` - -### Step 5: Update Hook Registrations - -Use the new `Hook` contract for better hook management. - -**Before:** - -```javascript -universe.registerHook('application:before-model', (session, router) => { - if (session.isCustomer) { - router.transitionTo('customer-portal'); - } -}); -``` - -**After:** - -```javascript -import { Hook } from '@fleetbase/ember-core/contracts'; - -universe.registerHook( - new Hook('application:before-model', (session, router) => { - if (session.isCustomer) { - router.transitionTo('customer-portal'); - } - }) - .withPriority(10) - .withId('customer-redirect') -); -``` - -## Backward Compatibility - -The refactored `UniverseService` maintains backward compatibility with the old API. You can continue using the old syntax while migrating: - -```javascript -// Old syntax still works -universe.registerHeaderMenuItem('My Item', 'my.route', { icon: 'star' }); - -// New syntax is preferred -universe.registerHeaderMenuItem( - new MenuItem('My Item', 'my.route').withIcon('star') -); -``` - -## Benefits of Migration - -1. **Performance**: Sub-second boot times with lazy loading -2. **Type Safety**: Contract classes provide validation and IDE support -3. **Maintainability**: Specialized services are easier to understand and modify -4. **Developer Experience**: Fluent API with method chaining -5. **Extensibility**: Easy to add new features without breaking changes - -## Common Patterns - -### Menu Item with Click Handler - -```javascript -new MenuItem('Track Order') - .withIcon('barcode') - .withType('link') - .withWrapperClass('btn-block py-1 border') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/order-tracking-lookup') - ) - .onClick((menuItem) => { - universe.transitionMenuItem('virtual', menuItem); - }) -``` - -### Widget with Refresh Interval - -```javascript -new Widget('live-metrics') - .withName('Live Metrics') - .withComponent( - new ExtensionComponent('@fleetbase/my-engine', 'components/widget/live-metrics') - .withLoadingComponent('skeletons/widget') - ) - .withRefreshInterval(5000) - .asDefault() -``` - -### Hook with Priority and Once - -```javascript -new Hook('order:before-save') - .withPriority(10) - .once() - .execute(async (order) => { - await validateOrder(order); - }) -``` - -## Troubleshooting - -### Component Not Found Error - -If you see "Component not found in engine" errors: - -1. Check that the component path is correct -2. Ensure the engine name matches exactly -3. Verify the component exists in the engine - -### Loading Spinner Not Showing - -If the loading spinner doesn't appear: - -1. Check that you're using `` in templates -2. Verify the `componentDef` is a lazy definition object -3. Ensure the loading component exists - -### Hooks Not Executing - -If hooks aren't running: - -1. Check the hook name matches exactly -2. Verify the hook is registered before it's needed -3. Use `universe.hookService.getHooks(hookName)` to debug - -## Support - -For questions or issues with the migration, please: - -1. Check the contract class documentation in `addon/contracts/` -2. Review the service documentation in `addon/services/universe/` -3. Open an issue on GitHub with details about your migration challenge - -## Timeline - -- **Phase 1**: Refactored services are available, old API still works -- **Phase 2**: Extensions migrate to new `extension.js` pattern -- **Phase 3**: Deprecation warnings for old patterns -- **Phase 4**: Old `setupExtension` pattern removed (future release) - -You can migrate at your own pace. The new architecture is fully backward compatible. diff --git a/UNIVERSE_REFACTOR_README.md b/UNIVERSE_REFACTOR_README.md deleted file mode 100644 index a5acb93f..00000000 --- a/UNIVERSE_REFACTOR_README.md +++ /dev/null @@ -1,220 +0,0 @@ -# UniverseService Refactor - -## Overview - -This refactor addresses critical performance and architectural issues in the UniverseService by decomposing it into specialized services, introducing a contract system, and implementing true lazy loading for engines. - -## Problems Solved - -### 1. Performance Bottleneck - -**Before**: 10-40 second initial load time due to sequential `bootEngines` process loading all extensions upfront. - -**After**: <1 second initial load time with on-demand lazy loading. - -### 2. Monolithic Design - -**Before**: 1,978 lines handling 7+ distinct responsibilities in a single service. - -**After**: Specialized services with clear separation of concerns: -- `ExtensionManager`: Engine lifecycle and lazy loading -- `RegistryService`: Registry management using Ember's container -- `MenuService`: Menu items and panels -- `WidgetService`: Dashboard widgets -- `HookService`: Application hooks - -### 3. Inefficient Registry - -**Before**: Custom object-based registry with O(n) lookups. - -**After**: Ember container-based registry with O(1) lookups. - -### 4. Broken Lazy Loading - -**Before**: `bootEngines` manually boots and initializes every engine, breaking lazy loading. - -**After**: Engines load on-demand when their components are actually needed. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ UniverseService (Facade) │ -│ Maintains backward compatibility while delegating to: │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌───────────────────┼───────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Extension │ │ Registry │ │ Menu │ -│ Manager │ │ Service │ │ Service │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ │ │ - ▼ ▼ ▼ -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Widget │ │ Hook │ │ Contract │ -│ Service │ │ Service │ │ System │ -└──────────────┘ └──────────────┘ └──────────────┘ -``` - -## Contract System - -New classes provide a fluent, type-safe API for extension definitions: - -```javascript -import { MenuItem, ExtensionComponent, Widget, Hook } from '@fleetbase/ember-core/contracts'; - -// Menu item with lazy component -new MenuItem('Fleet-Ops', 'console.fleet-ops') - .withIcon('route') - .withPriority(0) - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app') - ); - -// Widget with grid options -new Widget('fleet-ops-metrics') - .withName('Fleet-Ops Metrics') - .withIcon('truck') - .withComponent( - new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics') - ) - .withGridOptions({ w: 12, h: 12 }) - .asDefault(); - -// Hook with priority -new Hook('application:before-model', (session, router) => { - if (session.isCustomer) { - router.transitionTo('customer-portal'); - } -}) - .withPriority(10) - .once(); -``` - -## Lazy Loading Flow - -1. **Boot Time**: Only `extension.js` files are loaded (no engine code) -2. **Registration**: Metadata is registered (menus, widgets, hooks) -3. **Runtime**: When a component needs to render: - - `` triggers `extensionManager.ensureEngineLoaded()` - - Engine bundle is fetched and loaded - - Component is looked up from the engine - - Component is rendered - -## Extension Pattern - -### Old Pattern (engine.js) - -```javascript -import MyComponent from './components/my-component'; - -export default class MyEngine extends Engine { - setupExtension = function (app, engine, universe) { - universe.registerMenuItem('my-registry', 'My Item', { - component: MyComponent // Loads entire engine! - }); - }; -} -``` - -### New Pattern (extension.js) - -```javascript -import { MenuItem, ExtensionComponent } from '@fleetbase/ember-core/contracts'; - -export default function (app, universe) { - universe.registerMenuItem( - 'my-registry', - new MenuItem('My Item') - .withComponent( - new ExtensionComponent('@fleetbase/my-engine', 'components/my-component') - ) - ); -} -``` - -**Key Difference**: No component imports = no engine loading at boot time. - -## Performance Improvements - -| Metric | Before | After | Improvement | -|--------|--------|-------|-------------| -| Initial Load Time | 10-40s | <1s | ~90% faster | -| Bundle Size (initial) | Full app + all engines | Core app only | ~60% reduction | -| Lookup Performance | O(n) | O(1) | 100x faster | -| Timeout Errors | Frequent | None | 100% reduction | - -## Backward Compatibility - -The refactor is **100% backward compatible**. The old API still works: - -```javascript -// Old syntax (still works) -universe.registerHeaderMenuItem('My Item', 'my.route', { icon: 'star' }); - -// New syntax (preferred) -universe.registerHeaderMenuItem( - new MenuItem('My Item', 'my.route').withIcon('star') -); -``` - -## Migration - -See [UNIVERSE_REFACTOR_MIGRATION_GUIDE.md](./UNIVERSE_REFACTOR_MIGRATION_GUIDE.md) for detailed migration instructions. - -## Files Changed - -### New Files - -- `addon/contracts/` - Contract system classes - - `base-contract.js` - - `extension-component.js` - - `menu-item.js` - - `menu-panel.js` - - `hook.js` - - `widget.js` - - `registry.js` - - `index.js` - -- `addon/services/universe/` - Specialized services - - `extension-manager.js` - - `registry-service.js` - - `menu-service.js` - - `widget-service.js` - - `hook-service.js` - -- `addon/components/` - Lazy loading component - - `lazy-engine-component.js` - - `lazy-engine-component.hbs` - -### Modified Files - -- `addon/services/universe.js` - Refactored as facade -- `addon/services/legacy-universe.js` - Original service (for reference) - -## Testing - -The refactor includes: - -1. **Unit tests** for each contract class -2. **Integration tests** for each service -3. **Acceptance tests** for lazy loading behavior -4. **Performance benchmarks** comparing old vs new - -## Future Enhancements - -1. **TypeScript definitions** for contract classes -2. **Extension manifest validation** at build time -3. **Preloading strategies** for critical engines -4. **Memory management** for long-running applications -5. **Developer tools** for debugging extension loading - -## Credits - -Designed and implemented based on collaborative analysis with Ronald A Richardson, CTO of Fleetbase. - -## License - -MIT License - Copyright (c) 2025 Fleetbase From 3f075cf7bdbe6819181ac447f1ff9c071444720e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 11 Feb 2026 17:52:30 +0800 Subject: [PATCH 9/9] ran prettier --- addon/services/crud.js | 1 - addon/services/events.js | 68 +++++++++++++++---------------- addon/services/resource-action.js | 2 - 3 files changed, 32 insertions(+), 39 deletions(-) diff --git a/addon/services/crud.js b/addon/services/crud.js index eb6d0bc3..edb2ff53 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -262,7 +262,6 @@ export default class CrudService extends Service { * @memberof CrudService */ - @action import(modelName, options = {}) { // always lowercase modelname modelName = modelName.toLowerCase(); diff --git a/addon/services/events.js b/addon/services/events.js index 8c8cb017..c2393102 100644 --- a/addon/services/events.js +++ b/addon/services/events.js @@ -1,15 +1,14 @@ import Service, { inject as service } from '@ember/service'; import Evented from '@ember/object/evented'; -import { getOwner } from '@ember/application'; import config from 'ember-get-config'; /** * Events Service - * + * * Provides a centralized event tracking system for Fleetbase. * This service emits standardized events on both its own event bus and the universe service, * allowing components, services, and engines to subscribe and react to application events. - * + * * @class EventsService * @extends Service */ @@ -19,7 +18,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Tracks the creation of a resource - * + * * @param {Object} resource - The created resource/model * @param {Object} [props={}] - Additional properties to include */ @@ -27,17 +26,17 @@ export default class EventsService extends Service.extend(Evented) { const events = this.#getResourceEvents(resource, 'created'); const properties = this.#enrichProperties({ ...this.#getSafeProperties(resource), - ...props + ...props, }); - events.forEach(eventName => { + events.forEach((eventName) => { this.#trigger(eventName, resource, properties); }); } /** * Tracks the update of a resource - * + * * @param {Object} resource - The updated resource/model * @param {Object} [props={}] - Additional properties to include */ @@ -45,17 +44,17 @@ export default class EventsService extends Service.extend(Evented) { const events = this.#getResourceEvents(resource, 'updated'); const properties = this.#enrichProperties({ ...this.#getSafeProperties(resource), - ...props + ...props, }); - events.forEach(eventName => { + events.forEach((eventName) => { this.#trigger(eventName, resource, properties); }); } /** * Tracks the deletion of a resource - * + * * @param {Object} resource - The deleted resource/model * @param {Object} [props={}] - Additional properties to include */ @@ -63,17 +62,17 @@ export default class EventsService extends Service.extend(Evented) { const events = this.#getResourceEvents(resource, 'deleted'); const properties = this.#enrichProperties({ ...this.#getSafeProperties(resource), - ...props + ...props, }); - events.forEach(eventName => { + events.forEach((eventName) => { this.#trigger(eventName, resource, properties); }); } /** * Tracks a bulk import of resources - * + * * @param {String} modelName - The name of the model being imported * @param {Number} count - Number of resources imported * @param {Object} [props={}] - Additional properties to include @@ -82,7 +81,7 @@ export default class EventsService extends Service.extend(Evented) { const properties = this.#enrichProperties({ model_name: modelName, count: count, - ...props + ...props, }); this.#trigger('resource.imported', modelName, count, properties); @@ -90,7 +89,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Tracks a resource export - * + * * @param {String} modelName - The name of the model being exported * @param {String} format - Export format (csv, xlsx, etc.) * @param {Object} [params={}] - Export parameters/filters @@ -101,7 +100,7 @@ export default class EventsService extends Service.extend(Evented) { model_name: modelName, export_format: format, has_filters: !!(params && Object.keys(params).length > 0), - ...props + ...props, }); this.#trigger('resource.exported', modelName, format, params, properties); @@ -113,7 +112,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Tracks a bulk action on multiple resources - * + * * @param {String} verb - The action verb (delete, archive, etc.) * @param {Array} resources - Array of selected resources * @param {Object} [props={}] - Additional properties to include @@ -126,7 +125,7 @@ export default class EventsService extends Service.extend(Evented) { action: verb, count: resources?.length || 0, model_name: modelName, - ...props + ...props, }); this.#trigger('resource.bulk_action', verb, resources, firstResource, properties); @@ -134,7 +133,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Tracks when the current user is loaded (session initialized) - * + * * @param {Object} user - The user object * @param {Object} organization - The organization object * @param {Object} [props={}] - Additional properties to include @@ -144,7 +143,7 @@ export default class EventsService extends Service.extend(Evented) { user_id: user?.id, organization_id: organization?.id, organization_name: organization?.name, - ...props + ...props, }); this.#trigger('user.loaded', user, organization, properties); @@ -152,14 +151,14 @@ export default class EventsService extends Service.extend(Evented) { /** * Tracks when a user session is terminated - * + * * @param {Number} duration - Session duration in seconds * @param {Object} [props={}] - Additional properties to include */ trackSessionTerminated(duration, props = {}) { const properties = this.#enrichProperties({ session_duration: duration, - ...props + ...props, }); this.#trigger('session.terminated', duration, properties); @@ -167,7 +166,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Tracks a generic custom event - * + * * @param {String} eventName - The event name (dot notation) * @param {Object} [props={}] - Event properties */ @@ -178,7 +177,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Checks if event tracking is enabled - * + * * @returns {Boolean} */ isEnabled() { @@ -192,11 +191,11 @@ export default class EventsService extends Service.extend(Evented) { /** * Triggers an event on both the events service and universe service - * + * * This dual event system allows listeners to subscribe to events on either: * - this.events.on('event.name', handler) - Local listeners * - this.universe.on('event.name', handler) - Cross-engine listeners - * + * * @private * @param {String} eventName - The event name * @param {...*} args - Arguments to pass to event listeners @@ -224,7 +223,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Generates both generic and specific event names for a resource action - * + * * @private * @param {Object} resource - The resource/model * @param {String} action - The action (created, updated, deleted) @@ -232,15 +231,12 @@ export default class EventsService extends Service.extend(Evented) { */ #getResourceEvents(resource, action) { const modelName = this.#getModelName(resource); - return [ - `resource.${action}`, - `${modelName}.${action}` - ]; + return [`resource.${action}`, `${modelName}.${action}`]; } /** * Extracts safe, serializable properties from a resource - * + * * @private * @param {Object} resource - The resource/model * @returns {Object} Safe properties object @@ -252,12 +248,12 @@ export default class EventsService extends Service.extend(Evented) { const props = { id: resource.id, - model_name: this.#getModelName(resource) + model_name: this.#getModelName(resource), }; // Add common properties if available const commonProps = ['name', 'status', 'type', 'slug', 'public_id']; - commonProps.forEach(prop => { + commonProps.forEach((prop) => { if (resource[prop] !== undefined && resource[prop] !== null) { props[prop] = resource[prop]; } @@ -268,7 +264,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Enriches properties with user, organization, and timestamp context - * + * * @private * @param {Object} props - Base properties * @returns {Object} Enriched properties @@ -298,7 +294,7 @@ export default class EventsService extends Service.extend(Evented) { /** * Safely extracts the model name from a resource - * + * * @private * @param {Object} resource - The resource/model * @returns {String} Model name or 'unknown' diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js index 7df7ac18..3eea7cc8 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -442,8 +442,6 @@ export default class ResourceActionService extends Service { } } - - /** * Searches for records with debouncing. * Uses ember-concurrency for async handling with restartable behavior.