From 955a68b1b74e7a1c62b29ede392b2af1851c3fb0 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 8 May 2026 15:04:27 +0200 Subject: [PATCH] feat(analytics): extend analytics service with posthog-node SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing lib/services/analytics.js to wrap posthog-node : - New config block analytics.posthog (enabled, apiKey, host, appTag, flushAt, flushInterval) gated by POSTHOG_ENABLED env var. Default off so existing downstream projects without env stay untouched. - New service exports : identify(distinctId, properties), capture({ distinctId, event, properties }), shutdown(). Auto-injects app=appTag + env=NODE_ENV on every capture (custom properties win). - Auth controller wires identify + capture(user_signed_up) on signup and capture(user_signed_in) on signin (generic events — downstream projects add their own business events on top). - SIGTERM/SIGINT shutdown hook flushes pending events. - 3 new test files covering identify, capture, lifecycle resilience. Project-specific tagging via POSTHOG_APP_TAG=trawl|comes|... env var in each downstream's deployment manifest. PostHog Cloud target (https://eu.i.posthog.com default). --- .env.example | 6 +- config/defaults/development.config.js | 6 +- lib/services/analytics.js | 51 +++- .../tests/analytics.capture.unit.tests.js | 245 ++++++++++++++++++ .../tests/analytics.forRequest.unit.tests.js | 2 +- .../tests/analytics.identify.unit.tests.js | 138 ++++++++++ ...analytics.service.resilience.unit.tests.js | 2 +- .../tests/analytics.service.unit.tests.js | 4 +- modules/auth/controllers/auth.controller.js | 10 +- 9 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 lib/services/tests/analytics.capture.unit.tests.js diff --git a/.env.example b/.env.example index 2eb46935d..0d43800cc 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,9 @@ DEVKIT_NODE_stripe_prices_pro_monthly=price_xxx DEVKIT_NODE_stripe_prices_pro_annual=price_xxx # PostHog Analytics -# Get your keys from https://us.posthog.com/settings/project-api-key +# Get your keys from https://eu.posthog.com/settings/project-api-key +DEVKIT_NODE_posthog_enabled=true DEVKIT_NODE_posthog_apiKey=phc_xxx -DEVKIT_NODE_posthog_host=https://us.i.posthog.com +DEVKIT_NODE_posthog_host=https://eu.i.posthog.com +DEVKIT_NODE_posthog_appTag=myproject DEVKIT_NODE_posthog_personalApiKey=phx_xxx diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index bc6a776dc..1c12afa13 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -85,8 +85,12 @@ const config = { enabled: false, }, posthog: { + enabled: false, // set to true + apiKey to activate (default off, no breakage on unconfigured projects) // apiKey: process.env.DEVKIT_NODE_posthog_apiKey ?? '', - // host: process.env.DEVKIT_NODE_posthog_host ?? 'https://us.i.posthog.com', + // host: process.env.DEVKIT_NODE_posthog_host ?? 'https://eu.i.posthog.com', + // appTag: process.env.DEVKIT_NODE_posthog_appTag ?? '', // e.g. 'trawl', 'comes' — auto-injected on every capture + flushAt: 20, + flushInterval: 10000, errorTracking: false, // opt-in: capture exceptions to PostHog (default: off) autoCapture: false, // opt-in: auto-capture api_request events (default: off) }, diff --git a/lib/services/analytics.js b/lib/services/analytics.js index 83e489dd0..f1b237a5f 100644 --- a/lib/services/analytics.js +++ b/lib/services/analytics.js @@ -9,11 +9,19 @@ import config from '../../config/index.js'; */ let client = null; +/** + * Resolved at init time from config.posthog.appTag. + * Stored here so capture() doesn't re-read config on every call. + * @type {string|undefined} + */ +let _appTag; + /** * Initialise the PostHog client using application config. - * When `posthog.apiKey` is absent the service stays in no-op mode — - * every public method silently returns without side-effects so that - * downstream projects that don't use PostHog are never affected. + * When `posthog.enabled` is false OR `posthog.apiKey` is absent the service + * stays in no-op mode — every public method silently returns without + * side-effects so that downstream projects that don't use PostHog are + * never affected. * * The `posthog-node` SDK is lazy-loaded (dynamic import) so that * applications running on Node versions outside the SDK's engine @@ -21,10 +29,15 @@ let client = null; * @returns {Promise} */ const init = async () => { - const { apiKey, host } = config.posthog ?? {}; - if (!apiKey) return; + if (client) return; // already initialised — singleton guard + const { enabled, apiKey, host, flushAt, flushInterval, appTag } = config.posthog ?? {}; + if (!enabled || !apiKey) return; const { PostHog } = await import('posthog-node'); - client = new PostHog(apiKey, { host: host || 'https://us.i.posthog.com' }); + const options = { host: host || 'https://eu.i.posthog.com' }; + if (flushAt != null) options.flushAt = flushAt; + if (flushInterval != null) options.flushInterval = flushInterval; + client = new PostHog(apiKey, options); + _appTag = appTag; }; /** @@ -42,6 +55,30 @@ const track = (distinctId, event, properties, groups) => { } catch (_) { /* analytics must never break caller */ } }; +/** + * Capture an analytics event with automatic context injection. + * Auto-injects `app` (from config.posthog.appTag) and `env` (NODE_ENV) + * into every event. Custom properties take precedence over defaults. + * No-op when client is not initialised, distinctId or event are missing. + * + * @param {Object} params - Event parameters + * @param {string} params.distinctId - User or anonymous identifier + * @param {string} params.event - Event name + * @param {Object} [params.properties] - Additional event properties (win over defaults) + * @returns {void} + */ +const capture = ({ distinctId, event, properties = {} } = {}) => { + if (!client) return; + if (!distinctId || !event) return; + const defaults = { + env: process.env.NODE_ENV || 'development', + ...(_appTag ? { app: _appTag } : {}), + }; + try { + client.capture({ distinctId, event, properties: { ...defaults, ...properties } }); + } catch (_) { /* analytics must never break caller */ } +}; + /** * Identify a user with optional properties. * @param {string} distinctId - User identifier @@ -182,11 +219,13 @@ const shutdown = async () => { if (!client) return; await client.shutdown(); client = null; + _appTag = undefined; }; export default { init, track, + capture, identify, groupIdentify, getFeatureFlag, diff --git a/lib/services/tests/analytics.capture.unit.tests.js b/lib/services/tests/analytics.capture.unit.tests.js new file mode 100644 index 000000000..14d6febeb --- /dev/null +++ b/lib/services/tests/analytics.capture.unit.tests.js @@ -0,0 +1,245 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; + +/** + * Unit tests for AnalyticsService.capture() and enabled-flag behaviour. + */ +describe('Analytics capture() and enabled-flag:', () => { + let AnalyticsService; + let mockPostHogInstance; + + beforeEach(async () => { + jest.resetModules(); + + mockPostHogInstance = { + capture: jest.fn(), + identify: jest.fn(), + groupIdentify: jest.fn(), + getFeatureFlag: jest.fn().mockResolvedValue(undefined), + isFeatureEnabled: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + }; + + jest.unstable_mockModule('posthog-node', () => ({ + PostHog: jest.fn().mockImplementation(() => mockPostHogInstance), + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ───────────────────────────────────────────────────────────────── + // 1. enabled=false disables client creation + // ───────────────────────────────────────────────────────────────── + describe('enabled flag:', () => { + test('returns null client when enabled=false even if apiKey is present', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: false, apiKey: 'phc_test_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + AnalyticsService.capture({ distinctId: 'user-1', event: 'test' }); + + const { PostHog } = await import('posthog-node'); + expect(PostHog).not.toHaveBeenCalled(); + expect(mockPostHogInstance.capture).not.toHaveBeenCalled(); + }); + + test('returns null client when apiKey is missing even if enabled=true', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + AnalyticsService.capture({ distinctId: 'user-1', event: 'test' }); + + const { PostHog } = await import('posthog-node'); + expect(PostHog).not.toHaveBeenCalled(); + }); + + test('creates client when enabled=true and apiKey is present', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_test_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + + const { PostHog } = await import('posthog-node'); + expect(PostHog).toHaveBeenCalledWith('phc_test_key', expect.objectContaining({ host: 'https://eu.i.posthog.com' })); + }); + + test('passes flushAt and flushInterval to PostHog constructor', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com', flushAt: 20, flushInterval: 10000 } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + + const { PostHog } = await import('posthog-node'); + expect(PostHog).toHaveBeenCalledWith('phc_key', { + host: 'https://eu.i.posthog.com', + flushAt: 20, + flushInterval: 10000, + }); + }); + + test('singleton: two init() calls on the same module instance result in one PostHog client', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + await AnalyticsService.init(); // singleton guard: no-op, client already set + + const { PostHog } = await import('posthog-node'); + expect(PostHog).toHaveBeenCalledTimes(1); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // 2. capture() no-ops + // ───────────────────────────────────────────────────────────────── + describe('capture() no-ops:', () => { + test('is a no-op when client is null', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: false, apiKey: 'phc_key' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + AnalyticsService.capture({ distinctId: 'user-1', event: 'some_event' }); + + expect(mockPostHogInstance.capture).not.toHaveBeenCalled(); + }); + + test('is a no-op when distinctId is missing', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + AnalyticsService.capture({ event: 'some_event' }); + + expect(mockPostHogInstance.capture).not.toHaveBeenCalled(); + }); + + test('is a no-op when event is missing', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + + await AnalyticsService.init(); + AnalyticsService.capture({ distinctId: 'user-1' }); + + expect(mockPostHogInstance.capture).not.toHaveBeenCalled(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // 3. capture() auto-injects app + env + // ───────────────────────────────────────────────────────────────── + describe('capture() property injection:', () => { + beforeEach(async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com', appTag: 'myapp' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + await AnalyticsService.init(); + }); + + test('auto-injects app from appTag and env from NODE_ENV', async () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + + AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' }); + + expect(mockPostHogInstance.capture).toHaveBeenCalledWith({ + distinctId: 'user-1', + event: 'my_event', + properties: { app: 'myapp', env: 'test' }, + }); + + process.env.NODE_ENV = origEnv; + }); + + test('custom properties win over defaults', async () => { + AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event', properties: { app: 'override', custom: 'val' } }); + + expect(mockPostHogInstance.capture).toHaveBeenCalledWith({ + distinctId: 'user-1', + event: 'my_event', + properties: expect.objectContaining({ app: 'override', custom: 'val' }), + }); + }); + + test('does not inject app when appTag is not configured', async () => { + jest.resetModules(); + + jest.unstable_mockModule('posthog-node', () => ({ + PostHog: jest.fn().mockImplementation(() => mockPostHogInstance), + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + await AnalyticsService.init(); + + AnalyticsService.capture({ distinctId: 'user-1', event: 'my_event' }); + + const call = mockPostHogInstance.capture.mock.calls[0][0]; + expect(call.properties).not.toHaveProperty('app'); + expect(call.properties).toHaveProperty('env'); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // 4. shutdown idempotency + // ───────────────────────────────────────────────────────────────── + describe('shutdown idempotency:', () => { + test('two shutdown() calls invoke client.shutdown exactly once', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://eu.i.posthog.com' } }, + })); + + const mod = await import('../analytics.js'); + AnalyticsService = mod.default; + await AnalyticsService.init(); + + await AnalyticsService.shutdown(); + await AnalyticsService.shutdown(); + + expect(mockPostHogInstance.shutdown).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/services/tests/analytics.forRequest.unit.tests.js b/lib/services/tests/analytics.forRequest.unit.tests.js index 167cb3655..2f22317b6 100644 --- a/lib/services/tests/analytics.forRequest.unit.tests.js +++ b/lib/services/tests/analytics.forRequest.unit.tests.js @@ -34,7 +34,7 @@ describe('analytics request-aware feature-flag helpers:', () => { })); jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { posthog: { apiKey: 'phk_test', host: 'https://posthog.test' } }, + default: { posthog: { enabled: true, apiKey: 'phk_test', host: 'https://posthog.test' } }, })); const mod = await import('../analytics.js'); diff --git a/lib/services/tests/analytics.identify.unit.tests.js b/lib/services/tests/analytics.identify.unit.tests.js index 2ebb65bb2..3abb0be1c 100644 --- a/lib/services/tests/analytics.identify.unit.tests.js +++ b/lib/services/tests/analytics.identify.unit.tests.js @@ -10,17 +10,20 @@ import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globa describe('Analytics identify on auth events:', () => { let mockIdentify; let mockGroupIdentify; + let mockCapture; beforeEach(() => { jest.resetModules(); mockIdentify = jest.fn(); mockGroupIdentify = jest.fn(); + mockCapture = jest.fn(); jest.unstable_mockModule('../analytics.js', () => ({ default: { identify: mockIdentify, groupIdentify: mockGroupIdentify, + capture: mockCapture, track: jest.fn(), init: jest.fn(), shutdown: jest.fn(), @@ -154,6 +157,35 @@ describe('Analytics identify on auth events:', () => { }); }); + test('should call AnalyticsService.capture with user_signed_up after successful signup', async () => { + const mockUser = { + id: 'user-capture-1', + email: 'capture@example.com', + firstName: 'Cap', + lastName: 'Ture', + provider: 'local', + plan: 'free', + createdAt: new Date('2026-01-01'), + }; + setupSignupMocks(mockUser); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { body: { email: 'capture@example.com', firstName: 'Cap', lastName: 'Ture', password: 'StrongPass1!' } }; + const res = { + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await AuthController.signup(req, res); + + expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({ + distinctId: 'user-capture-1', + event: 'user_signed_up', + })); + }); + test('should not break signup if AnalyticsService.identify throws', async () => { mockIdentify.mockImplementation(() => { throw new Error('PostHog down'); }); @@ -286,6 +318,108 @@ describe('Analytics identify on auth events:', () => { lastLoginAt: new Date('2026-01-01'), }); }); + + test('should call AnalyticsService.capture with user_signed_in after successful signin', async () => { + const mockUser = { + id: 'user-signin-cap', + _id: 'user-signin-cap', + email: 'signincap@example.com', + firstName: 'Sign', + lastName: 'In', + lastLoginAt: new Date('2026-01-02'), + currentOrganization: null, + }; + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { getBrut: jest.fn(), update: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { handleSignupOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + }, + })); + + jest.unstable_mockModule('../../middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + jest.unstable_mockModule('../../helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {} }, + })); + + jest.unstable_mockModule('../../middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + jest.unstable_mockModule('../logger.js', () => ({ + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { user: mockUser }; + const res = { + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await AuthController.signin(req, res); + + expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({ + distinctId: 'user-signin-cap', + event: 'user_signed_in', + })); + }); }); }); @@ -301,6 +435,7 @@ describe('Analytics groupIdentify on organization events:', () => { default: { identify: jest.fn(), groupIdentify: mockGroupIdentify, + capture: jest.fn(), track: jest.fn(), init: jest.fn(), shutdown: jest.fn(), @@ -454,6 +589,7 @@ describe('Analytics groupIdentify on billing plan.changed event:', () => { default: { identify: jest.fn(), groupIdentify: mockGroupIdentify, + capture: jest.fn(), track: jest.fn(), init: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn(), @@ -464,6 +600,7 @@ describe('Analytics groupIdentify on billing plan.changed event:', () => { default: { identify: jest.fn(), groupIdentify: mockGroupIdentify, + capture: jest.fn(), track: jest.fn(), init: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn(), @@ -474,6 +611,7 @@ describe('Analytics groupIdentify on billing plan.changed event:', () => { default: { identify: jest.fn(), groupIdentify: mockGroupIdentify, + capture: jest.fn(), track: jest.fn(), init: jest.fn().mockResolvedValue(undefined), shutdown: jest.fn(), diff --git a/lib/services/tests/analytics.service.resilience.unit.tests.js b/lib/services/tests/analytics.service.resilience.unit.tests.js index 30b1767cb..490feed7f 100644 --- a/lib/services/tests/analytics.service.resilience.unit.tests.js +++ b/lib/services/tests/analytics.service.resilience.unit.tests.js @@ -28,7 +28,7 @@ describe('Analytics service resilience tests:', () => { })); jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { posthog: { apiKey: 'phc_key', host: 'https://test.posthog.com' } }, + default: { posthog: { enabled: true, apiKey: 'phc_key', host: 'https://test.posthog.com' } }, })); const mod = await import('../analytics.js'); diff --git a/lib/services/tests/analytics.service.unit.tests.js b/lib/services/tests/analytics.service.unit.tests.js index fb63f6730..301cf8386 100644 --- a/lib/services/tests/analytics.service.unit.tests.js +++ b/lib/services/tests/analytics.service.unit.tests.js @@ -110,6 +110,7 @@ describe('Analytics service unit tests:', () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: { posthog: { + enabled: true, apiKey: 'phc_test_key', host: 'https://test.posthog.com', }, @@ -136,6 +137,7 @@ describe('Analytics service unit tests:', () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: { posthog: { + enabled: true, apiKey: 'phc_test_key', host: '', }, @@ -148,7 +150,7 @@ describe('Analytics service unit tests:', () => { await AnalyticsService.init(); const { PostHog } = await import('posthog-node'); - expect(PostHog).toHaveBeenCalledWith('phc_test_key', { host: 'https://us.i.posthog.com' }); + expect(PostHog).toHaveBeenCalledWith('phc_test_key', { host: 'https://eu.i.posthog.com' }); }); test('track should call client.capture with correct params', async () => { diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index b6482ac2b..1e6b96ecc 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -107,7 +107,7 @@ const signup = async (req, res) => { throw orgErr; } - // Analytics identify — fire-and-forget, never break signup flow + // Analytics — fire-and-forget, never break signup flow try { AnalyticsService.identify(String(user.id), { email: user.email, @@ -115,6 +115,11 @@ const signup = async (req, res) => { lastName: user.lastName, provider: user.provider, }); + AnalyticsService.capture({ + distinctId: String(user.id), + event: 'user_signed_up', + properties: { email: user.email, plan: user.plan, createdAt: user.createdAt }, + }); } catch (_) { /* analytics must not break auth */ } const token = jwt.sign({ userId: user.id }, config.jwt.secret, { @@ -193,7 +198,7 @@ const signin = async (req, res) => { ); } - // Analytics identify — fire-and-forget, never break signin flow + // Analytics — fire-and-forget, never break signin flow try { AnalyticsService.identify(String(user.id || user._id), { email: user.email, @@ -201,6 +206,7 @@ const signin = async (req, res) => { lastName: user.lastName, lastLoginAt: user.lastLoginAt, }); + AnalyticsService.capture({ distinctId: String(user.id || user._id), event: 'user_signed_in' }); } catch (_) { /* analytics must not break auth */ } const token = jwt.sign({ userId: user.id }, config.jwt.secret, {