diff --git a/EVENTS_SERVICE.md b/EVENTS_SERVICE.md new file mode 100644 index 0000000..32969e2 --- /dev/null +++ b/EVENTS_SERVICE.md @@ -0,0 +1,475 @@ +# 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/addon/exports/host-services.js b/addon/exports/host-services.js index cc8d3f6..f900dd2 100644 --- a/addon/exports/host-services.js +++ b/addon/exports/host-services.js @@ -19,6 +19,7 @@ export const hostServices = [ 'sidebar', 'dashboard', 'universe', + 'events', 'intl', 'abilities', 'language', diff --git a/addon/exports/services.js b/addon/exports/services.js index 0f728bb..b51f3d2 100644 --- a/addon/exports/services.js +++ b/addon/exports/services.js @@ -21,6 +21,7 @@ export const services = [ 'sidebar', 'dashboard', 'universe', + 'events', 'intl', 'abilities', 'language', diff --git a/addon/services/crud.js b/addon/services/crud.js index 2e7beff..eb6d0bc 100644 --- a/addon/services/crud.js +++ b/addon/services/crud.js @@ -17,6 +17,8 @@ export default class CrudService extends Service { @service notifications; @service store; @service currentUser; + @service universe; + @service events; /** * Generic deletion modal with options @@ -43,6 +45,10 @@ export default class CrudService extends Service { try { const response = await model.destroyRecord(); this.notifications.success(successNotification); + + // Track deletion event + this.events.trackResourceDeleted(model); + if (typeof options.onSuccess === 'function') { options.onSuccess(model); } @@ -161,6 +167,10 @@ export default class CrudService extends Service { ); this.notifications.success(response.message ?? successMessage); + + // Track bulk action event + this.events.trackBulkAction(verb, selected); + if (typeof options.onSuccess === 'function') { options.onSuccess(selected); } @@ -224,6 +234,9 @@ export default class CrudService extends Service { } ) .then(() => { + // Track export event + this.events.trackResourceExported(modelName, format, exportParams); + later( this, () => { @@ -248,6 +261,8 @@ export default class CrudService extends Service { * @param {Object} [options={}] * @memberof CrudService */ + + @action import(modelName, options = {}) { // always lowercase modelname modelName = modelName.toLowerCase(); @@ -337,6 +352,11 @@ export default class CrudService extends Service { try { const response = await this.fetch.post(importEndpoint, { files }, fetchOptions); + + // Track import event + const importCount = response?.imported?.length || response?.count || files.length; + 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 b174a9c..63553ff 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 events; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; diff --git a/addon/services/events.js b/addon/services/events.js new file mode 100644 index 0000000..8c8cb01 --- /dev/null +++ b/addon/services/events.js @@ -0,0 +1,326 @@ +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 + */ +export default class EventsService extends Service.extend(Evented) { + @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 event tracking is enabled + * + * @returns {Boolean} + */ + isEnabled() { + const eventsConfig = config?.events || {}; + return eventsConfig.enabled !== false; // Enabled by default + } + + // ========================================================================= + // Private Methods (using # syntax) + // ========================================================================= + + /** + * 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 + */ + #trigger(eventName, ...args) { + if (!this.isEnabled()) { + return; + } + + // Debug logging if enabled + if (config?.events?.debug) { + console.log(`[Events] ${eventName}`, args); + } + + // 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('[Events] Universe service not available'); + } + } + + /** + * 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 eventsConfig = config?.events || {}; + const enrichConfig = eventsConfig.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/resource-action.js b/addon/services/resource-action.js index f79d1e2..7df7ac1 100644 --- a/addon/services/resource-action.js +++ b/addon/services/resource-action.js @@ -29,6 +29,8 @@ export default class ResourceActionService extends Service { @service abilities; @service tableContext; @service resourceContextPanel; + @service universe; + @service events; /** * Getter for router, attempt to use hostRouter if from engine @@ -299,6 +301,9 @@ export default class ResourceActionService extends Service { }) ); + // Track creation event + this.events.trackResourceCreated(record); + if (options.refresh) { this.refresh(); } @@ -329,6 +334,9 @@ export default class ResourceActionService extends Service { }) ); + // Track update event + this.events.trackResourceUpdated(record); + if (options.refresh) { this.refresh(); } @@ -362,6 +370,13 @@ export default class ResourceActionService extends Service { }) ); + // Track save event (create or update) + if (isNew) { + this.events.trackResourceCreated(record); + } else { + this.events.trackResourceUpdated(record); + } + if (options.refresh) { this.refresh(); } @@ -409,6 +424,9 @@ export default class ResourceActionService extends Service { }) ); + // Track deletion event + this.events.trackResourceDeleted(record); + if (options.refresh) { this.refresh(); } @@ -424,6 +442,8 @@ export default class ResourceActionService extends Service { } } + + /** * Searches for records with debouncing. * Uses ember-concurrency for async handling with restartable behavior. diff --git a/app/services/events.js b/app/services/events.js new file mode 100644 index 0000000..c73e6c6 --- /dev/null +++ b/app/services/events.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/services/events';