diff --git a/packages/core/framework/src/types/container.ts b/packages/core/framework/src/types/container.ts index 0c3ffad645..65e1c2a43a 100644 --- a/packages/core/framework/src/types/container.ts +++ b/packages/core/framework/src/types/container.ts @@ -1,6 +1,7 @@ import { Link } from "@medusajs/modules-sdk" import { ConfigModule, + IAnalyticsModuleService, IApiKeyModuleService, IAuthModuleService, ICacheService, @@ -48,6 +49,7 @@ declare module "@medusajs/types" { [ContainerRegistrationKeys.REMOTE_QUERY]: RemoteQueryFunction [ContainerRegistrationKeys.QUERY]: Omit [ContainerRegistrationKeys.LOGGER]: Logger + [Modules.ANALYTICS]: IAnalyticsModuleService [Modules.AUTH]: IAuthModuleService [Modules.CACHE]: ICacheService [Modules.CART]: ICartModuleService diff --git a/packages/core/types/src/analytics/index.ts b/packages/core/types/src/analytics/index.ts new file mode 100644 index 0000000000..cb9c116149 --- /dev/null +++ b/packages/core/types/src/analytics/index.ts @@ -0,0 +1,4 @@ +export * from "./mutations" +export * from "./service" +export * from "./provider" +export * from "./providers" diff --git a/packages/core/types/src/analytics/mutations.ts b/packages/core/types/src/analytics/mutations.ts new file mode 100644 index 0000000000..308b5796b5 --- /dev/null +++ b/packages/core/types/src/analytics/mutations.ts @@ -0,0 +1,39 @@ +export interface TrackAnalyticsEventDTO { + /** + * The event name + */ + event: string + /** + * The actor of the event, if there is any + */ + actor_id?: string + /** + * The group that the event is for, such as an organization or team. + * The "type" defines the name of the group (eg. "organization"), and the "id" is the id of the group. + */ + group?: { + type?: string + id?: string + } + /** + * The properties of the event. The format and content depends on the provider. + */ + properties?: Record +} + +export interface IdentifyActorDTO { + actor_id: string + properties?: Record +} + +export interface IdentifyGroupDTO { + group: { + type: string + id: string + } + // When identifying a group, the actor can potentially be passed as well as metadata. + actor_id?: string + properties?: Record +} +// Either actor_id or group must be provided. Depending on the provided identifier, the properties will be set for the actor or group. +export type IdentifyAnalyticsEventDTO = IdentifyActorDTO | IdentifyGroupDTO diff --git a/packages/core/types/src/analytics/provider.ts b/packages/core/types/src/analytics/provider.ts new file mode 100644 index 0000000000..dc236c408b --- /dev/null +++ b/packages/core/types/src/analytics/provider.ts @@ -0,0 +1,33 @@ +import { IdentifyAnalyticsEventDTO, TrackAnalyticsEventDTO } from "./mutations" + +export type ProviderTrackAnalyticsEventDTO = TrackAnalyticsEventDTO + +export type ProviderIdentifyAnalyticsEventDTO = IdentifyAnalyticsEventDTO + +export interface IAnalyticsProvider { + /** + * This method is used to track an event in the analytics provider + * + * @param {ProviderTrackAnalyticsEventDTO} data - The data for the event. + * @returns {Promise} Resolves when the event is tracked successfully. + * + */ + track(data: ProviderTrackAnalyticsEventDTO): Promise + + /** + * This method is used to identify an actor or group in the analytics provider + * + * @param {ProviderIdentifyAnalyticsEventDTO} data - The data for the actor or group.. + * @returns {Promise} Resolves when the event is tracked successfully. + * + */ + identify(data: ProviderIdentifyAnalyticsEventDTO): Promise + + /** + * This method is used to shutdown the analytics provider, and flush all data before shutting down. + * + * @returns {Promise} Resolves when the provider is shutdown successfully. + * + */ + shutdown?(): Promise +} diff --git a/packages/core/types/src/analytics/providers/index.ts b/packages/core/types/src/analytics/providers/index.ts new file mode 100644 index 0000000000..5f69bfcf09 --- /dev/null +++ b/packages/core/types/src/analytics/providers/index.ts @@ -0,0 +1,2 @@ +export * from "./posthog" +export * from "./local" diff --git a/packages/core/types/src/analytics/providers/local.ts b/packages/core/types/src/analytics/providers/local.ts new file mode 100644 index 0000000000..808521d077 --- /dev/null +++ b/packages/core/types/src/analytics/providers/local.ts @@ -0,0 +1 @@ +export interface LocalAnalyticsServiceOptions {} diff --git a/packages/core/types/src/analytics/providers/posthog.ts b/packages/core/types/src/analytics/providers/posthog.ts new file mode 100644 index 0000000000..323917723d --- /dev/null +++ b/packages/core/types/src/analytics/providers/posthog.ts @@ -0,0 +1,10 @@ +export interface PosthogAnalyticsServiceOptions { + /** + * The key for the posthog events + */ + posthogEventsKey: string + /** + * The endpoint for the posthog server + */ + posthogHost: string +} diff --git a/packages/core/types/src/analytics/service.ts b/packages/core/types/src/analytics/service.ts new file mode 100644 index 0000000000..81cd93f219 --- /dev/null +++ b/packages/core/types/src/analytics/service.ts @@ -0,0 +1,45 @@ +import { IModuleService } from "../modules-sdk" +import { IdentifyAnalyticsEventDTO, TrackAnalyticsEventDTO } from "./mutations" +import { IAnalyticsProvider } from "./provider" + +export interface IAnalyticsModuleService extends IModuleService { + /** + * Returns a reference to the analytics provider in use + */ + getProvider(): IAnalyticsProvider + + /** + * This method tracks an event in the analytics provider + * + * @param {TrackAnalyticsEventDTO} data - The data for the event. + * @returns {Promise} Resolves when the event is tracked successfully. + * + * + * @example + * await analyticsModuleService.track({ + * event: "product_viewed", + * properties: { + * product_id: "123", + * product_name: "Product Name" + * } + * }) + */ + track(data: TrackAnalyticsEventDTO): Promise + + /** + * This method identifies an actor or group in the analytics provider + * + * @param {IdentifyAnalyticsEventDTO} data - The data for the actor or group. + * @returns {Promise} Resolves when the actor or group is identified successfully. + * + * + * @example + * await analyticsModuleService.identify({ + * actor_id: "123", + * properties: { + * name: "John Doe" + * } + * }) + */ + identify(data: IdentifyAnalyticsEventDTO): Promise +} diff --git a/packages/core/types/src/bundles.ts b/packages/core/types/src/bundles.ts index 5378c65f02..a628ab6127 100644 --- a/packages/core/types/src/bundles.ts +++ b/packages/core/types/src/bundles.ts @@ -1,4 +1,5 @@ export * as AdminTypes from "./admin" +export * as AnalyticsTypes from "./analytics" export * as ApiKeyTypes from "./api-key" export * as AuthTypes from "./auth" export * as CacheTypes from "./cache" diff --git a/packages/core/types/src/index.ts b/packages/core/types/src/index.ts index ee79df88a5..e6905e70b4 100644 --- a/packages/core/types/src/index.ts +++ b/packages/core/types/src/index.ts @@ -1,5 +1,6 @@ export * from "./address" export * from "./admin" +export * from "./analytics" export * from "./api-key" export * from "./auth" export * from "./bundles" diff --git a/packages/core/utils/src/analytics/abstract-analytics-provider.ts b/packages/core/utils/src/analytics/abstract-analytics-provider.ts new file mode 100644 index 0000000000..84529e1571 --- /dev/null +++ b/packages/core/utils/src/analytics/abstract-analytics-provider.ts @@ -0,0 +1,121 @@ +import { + IAnalyticsProvider, + ProviderIdentifyAnalyticsEventDTO, + ProviderTrackAnalyticsEventDTO, +} from "@medusajs/types" + +/** + * ### constructor + * + * The constructor allows you to access resources from the module's container using the first parameter, + * and the module's options using the second parameter. + * + * If you're creating a client or establishing a connection with a third-party service, do it in the constructor. + * + * #### Example + * + * ```ts + * import { Logger } from "@medusajs/framework/types" + * import { AbstractAnalyticsProviderService } from "@medusajs/framework/utils" + * + * type InjectedDependencies = { + * logger: Logger + * } + * + * type Options = { + * apiKey: string + * } + * + * class MyAnalyticsProviderService extends AbstractAnalyticsProviderService { + * protected logger_: Logger + * protected options_: Options + * static identifier = "my-analytics" + * // assuming you're initializing a client + * protected client + * + * constructor ( + * { logger }: InjectedDependencies, + * options: Options + * ) { + * super() + * + * this.logger_ = logger + * this.options_ = options + * + * // assuming you're initializing a client + * this.client = new Client(options) + * } + * } + * + * export default MyAnalyticsProviderService + * ``` + */ +export class AbstractAnalyticsProviderService implements IAnalyticsProvider { + /** + * Each analytics provider has a unique ID used to identify it. The provider's ID + * will be stored as `aly_{identifier}_{id}`, where `{id}` is the provider's `id` + * property in the `medusa-config.ts`. + * + * @example + * class MyAnalyticsProviderService extends AbstractAnalyticsProviderService { + * static identifier = "my-analytics" + * // ... + * } + */ + static identifier: string + + /** + * @ignore + */ + getIdentifier() { + return (this.constructor as any).identifier + } + + /** + * This method tracks an event using your provider's semantics + * + * This method will be used when tracking events to third-party providers. + * + * @param {ProviderTrackAnalyticsEventDTO} data - The data for the event. + * @returns {Promise} Resolves when the event is tracked successfully. + * + * @example + * class MyAnalyticsProviderService extends AbstractAnalyticsProviderService { + * // ... + * async track( + * data: ProviderTrackAnalyticsEventDTO + * ): Promise { + * // track event to third-party provider + * // or using custom logic + * // for example: + * this.client.track(data) + * } + * } + */ + async track(data: ProviderTrackAnalyticsEventDTO): Promise { + throw Error("track must be overridden by the child class") + } + + /** + * This method identifies an actor or group in the analytics provider + * + * @param {ProviderIdentifyAnalyticsEventDTO} data - The data for the actor or group. + * @returns {Promise} Resolves when the actor or group is identified successfully. + * + * @example + * class MyAnalyticsProviderService extends AbstractAnalyticsProviderService { + * // ... + * async identify( + * data: ProviderIdentifyAnalyticsEventDTO + * ): Promise { + * // identify actor or group in the analytics provider + * // or using custom logic + * // for example: + * this.client.identify(data) + * } + * } + */ + async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise { + throw Error("identify must be overridden by the child class") + } +} diff --git a/packages/core/utils/src/analytics/index.ts b/packages/core/utils/src/analytics/index.ts new file mode 100644 index 0000000000..82b6ba6bdd --- /dev/null +++ b/packages/core/utils/src/analytics/index.ts @@ -0,0 +1 @@ +export * from "./abstract-analytics-provider" diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 5142f288da..44f1308c83 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -1,4 +1,5 @@ export * from "./api-key" +export * from "./analytics" export * from "./auth" export * from "./bundles" export * from "./common" diff --git a/packages/core/utils/src/modules-sdk/definition.ts b/packages/core/utils/src/modules-sdk/definition.ts index a364b489df..31223e6673 100644 --- a/packages/core/utils/src/modules-sdk/definition.ts +++ b/packages/core/utils/src/modules-sdk/definition.ts @@ -1,4 +1,5 @@ export const Modules = { + ANALYTICS: "analytics", AUTH: "auth", CACHE: "cache", CART: "cart", @@ -28,6 +29,7 @@ export const Modules = { } as const export const MODULE_PACKAGE_NAMES = { + [Modules.ANALYTICS]: "@medusajs/medusa/analytics", [Modules.AUTH]: "@medusajs/medusa/auth", [Modules.CACHE]: "@medusajs/medusa/cache-inmemory", [Modules.CART]: "@medusajs/medusa/cart", diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 67ea672b21..5e211974cd 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -66,6 +66,9 @@ "@inquirer/checkbox": "^2.3.11", "@inquirer/input": "^2.2.9", "@medusajs/admin-bundler": "2.8.2", + "@medusajs/analytics": "2.8.2", + "@medusajs/analytics-local": "2.8.2", + "@medusajs/analytics-posthog": "2.8.2", "@medusajs/api-key": "2.8.2", "@medusajs/auth": "2.8.2", "@medusajs/auth-emailpass": "2.8.2", diff --git a/packages/medusa/src/modules/analytics-local.ts b/packages/medusa/src/modules/analytics-local.ts new file mode 100644 index 0000000000..f23168e5eb --- /dev/null +++ b/packages/medusa/src/modules/analytics-local.ts @@ -0,0 +1,6 @@ +import AnalyticsLocalModule from "@medusajs/analytics-local" + +export * from "@medusajs/analytics-local" + +export default AnalyticsLocalModule +export const discoveryPath = require.resolve("@medusajs/analytics-local") diff --git a/packages/medusa/src/modules/analytics-posthog.ts b/packages/medusa/src/modules/analytics-posthog.ts new file mode 100644 index 0000000000..d1ee6094c0 --- /dev/null +++ b/packages/medusa/src/modules/analytics-posthog.ts @@ -0,0 +1,6 @@ +import AnalyticsPosthogModule from "@medusajs/analytics-posthog" + +export * from "@medusajs/analytics-posthog" + +export default AnalyticsPosthogModule +export const discoveryPath = require.resolve("@medusajs/analytics-posthog") diff --git a/packages/medusa/src/modules/analytics.ts b/packages/medusa/src/modules/analytics.ts new file mode 100644 index 0000000000..629d270701 --- /dev/null +++ b/packages/medusa/src/modules/analytics.ts @@ -0,0 +1,6 @@ +import AnalyticsModule from "@medusajs/analytics" + +export * from "@medusajs/analytics" + +export default AnalyticsModule +export const discoveryPath = require.resolve("@medusajs/analytics") diff --git a/packages/modules/analytics/.gitignore b/packages/modules/analytics/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/modules/analytics/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/modules/analytics/CHANGELOG.md b/packages/modules/analytics/CHANGELOG.md new file mode 100644 index 0000000000..a8fc0f29bd --- /dev/null +++ b/packages/modules/analytics/CHANGELOG.md @@ -0,0 +1 @@ +# @medusajs/analytics diff --git a/packages/modules/analytics/README.md b/packages/modules/analytics/README.md new file mode 100644 index 0000000000..74eb90e785 --- /dev/null +++ b/packages/modules/analytics/README.md @@ -0,0 +1 @@ +# Analytics Module diff --git a/packages/modules/analytics/integration-tests/__fixtures__/providers/default-provider.ts b/packages/modules/analytics/integration-tests/__fixtures__/providers/default-provider.ts new file mode 100644 index 0000000000..4728304170 --- /dev/null +++ b/packages/modules/analytics/integration-tests/__fixtures__/providers/default-provider.ts @@ -0,0 +1,23 @@ +import { + ProviderIdentifyAnalyticsEventDTO, + ProviderTrackAnalyticsEventDTO, +} from "@medusajs/framework/types" +import { AbstractAnalyticsProviderService } from "@medusajs/framework/utils" + +export class AnalyticsProviderServiceFixtures extends AbstractAnalyticsProviderService { + static identifier = "fixtures-analytics-provider" + + async track(data: ProviderTrackAnalyticsEventDTO): Promise { + return Promise.resolve() + } + + async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise { + return Promise.resolve() + } + + async shutdown(): Promise { + return Promise.resolve() + } +} + +export const services = [AnalyticsProviderServiceFixtures] diff --git a/packages/modules/analytics/integration-tests/__fixtures__/providers/index.ts b/packages/modules/analytics/integration-tests/__fixtures__/providers/index.ts new file mode 100644 index 0000000000..e19230b5b7 --- /dev/null +++ b/packages/modules/analytics/integration-tests/__fixtures__/providers/index.ts @@ -0,0 +1 @@ +export * from "./default-provider" diff --git a/packages/modules/analytics/integration-tests/__tests__/module.spec.ts b/packages/modules/analytics/integration-tests/__tests__/module.spec.ts new file mode 100644 index 0000000000..f8321a5981 --- /dev/null +++ b/packages/modules/analytics/integration-tests/__tests__/module.spec.ts @@ -0,0 +1,105 @@ +import { moduleIntegrationTestRunner } from "@medusajs/test-utils" +import { Modules } from "@medusajs/framework/utils" +import { resolve } from "path" +import { IAnalyticsModuleService } from "@medusajs/types" +import { AnalyticsProviderServiceFixtures } from "../__fixtures__/providers/default-provider" + +jest.setTimeout(100000) + +const moduleOptions = { + providers: [ + { + resolve: resolve( + process.cwd() + + "/integration-tests/__fixtures__/providers/default-provider" + ), + id: "default-provider", + }, + ], +} + +moduleIntegrationTestRunner({ + moduleName: Modules.ANALYTICS, + moduleOptions: moduleOptions, + testSuite: ({ service }) => { + describe("Analytics Module Service", () => { + let spies: { + track: jest.SpyInstance + identify: jest.SpyInstance + } + + beforeAll(async () => { + spies = { + track: jest.spyOn( + AnalyticsProviderServiceFixtures.prototype, + "track" + ), + identify: jest.spyOn( + AnalyticsProviderServiceFixtures.prototype, + "identify" + ), + } + }) + + afterEach(async () => { + jest.clearAllMocks() + }) + + it("should call the provider's track method", async () => { + await service.track({ + event: "test-event", + actor_id: "test-user", + properties: { + test: "test", + }, + }) + + expect(spies.track).toHaveBeenCalledWith({ + event: "test-event", + actor_id: "test-user", + properties: { + test: "test", + }, + }) + }) + + it("should call the provider's identify method to identify an actor", async () => { + await service.identify({ + actor_id: "test-user", + properties: { + test: "test", + }, + }) + + expect(spies.identify).toHaveBeenCalledWith({ + actor_id: "test-user", + properties: { + test: "test", + }, + }) + }) + + it("should call the provider's identify method to identify a group", async () => { + await service.identify({ + group: { + type: "organization", + id: "test-organization", + }, + properties: { + test: "test", + }, + }) + + expect(spies.identify).toHaveBeenCalledWith({ + group: { + type: "organization", + id: "test-organization", + }, + properties: { + test: "test", + }, + }) + }) + }) + }, +}) diff --git a/packages/modules/analytics/jest.config.js b/packages/modules/analytics/jest.config.js new file mode 100644 index 0000000000..3aab9b7072 --- /dev/null +++ b/packages/modules/analytics/jest.config.js @@ -0,0 +1,10 @@ +const defineJestConfig = require("../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + "^@utils": "/src/utils", + }, +}) diff --git a/packages/modules/analytics/package.json b/packages/modules/analytics/package.json new file mode 100644 index 0000000000..1e3ee8fb45 --- /dev/null +++ b/packages/modules/analytics/package.json @@ -0,0 +1,48 @@ +{ + "name": "@medusajs/analytics", + "version": "2.8.2", + "description": "Medusa Analytics module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/analytics" + }, + "publishConfig": { + "access": "public" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "watch": "tsc --build --watch", + "watch:test": "tsc --build tsconfig.spec.json --watch", + "resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json", + "build": "rimraf dist && tsc --build && npm run resolve:aliases", + "test": "jest --runInBand --passWithNoTests --bail --forceExit -- src", + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts" + }, + "devDependencies": { + "@medusajs/framework": "2.8.2", + "@medusajs/test-utils": "2.8.2", + "@swc/core": "^1.7.28", + "@swc/jest": "^0.2.36", + "jest": "^29.7.0", + "rimraf": "^3.0.2", + "tsc-alias": "^1.8.6", + "typescript": "^5.6.2" + }, + "peerDependencies": { + "@medusajs/framework": "2.8.2", + "awilix": "^8.0.1" + } +} diff --git a/packages/modules/analytics/src/index.ts b/packages/modules/analytics/src/index.ts new file mode 100644 index 0000000000..f14a4ab313 --- /dev/null +++ b/packages/modules/analytics/src/index.ts @@ -0,0 +1,8 @@ +import { Module, Modules } from "@medusajs/framework/utils" +import AnalyticsService from "./services/analytics-service" +import loadProviders from "./loaders/providers" + +export default Module(Modules.ANALYTICS, { + service: AnalyticsService, + loaders: [loadProviders], +}) diff --git a/packages/modules/analytics/src/loaders/providers.ts b/packages/modules/analytics/src/loaders/providers.ts new file mode 100644 index 0000000000..2a141fc597 --- /dev/null +++ b/packages/modules/analytics/src/loaders/providers.ts @@ -0,0 +1,45 @@ +import { moduleProviderLoader } from "@medusajs/framework/modules-sdk" +import { + LoaderOptions, + ModuleProvider, + ModulesSdkTypes, +} from "@medusajs/framework/types" +import { asFunction, asValue, Lifetime } from "awilix" +import ProviderService, { + AnalyticsProviderIdentifierRegistrationName, + AnalyticsProviderRegistrationPrefix, +} from "../services/provider-service" + +const registrationFn = async (klass, container, pluginOptions) => { + const key = ProviderService.getRegistrationIdentifier(klass, pluginOptions.id) + + container.register({ + [AnalyticsProviderRegistrationPrefix + key]: asFunction( + (cradle) => new klass(cradle, pluginOptions.options ?? {}), + { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + } + ), + }) + + container.registerAdd( + AnalyticsProviderIdentifierRegistrationName, + asValue(key) + ) +} + +export default async ({ + container, + options, +}: LoaderOptions< + ( + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + ) & { providers: ModuleProvider[] } +>): Promise => { + await moduleProviderLoader({ + container, + providers: options?.providers || [], + registerServiceFn: registrationFn, + }) +} diff --git a/packages/modules/analytics/src/services/analytics-service.ts b/packages/modules/analytics/src/services/analytics-service.ts new file mode 100644 index 0000000000..5aa1055b6c --- /dev/null +++ b/packages/modules/analytics/src/services/analytics-service.ts @@ -0,0 +1,52 @@ +import { + TrackAnalyticsEventDTO, + IdentifyAnalyticsEventDTO, +} from "@medusajs/types" +import AnalyticsProviderService from "./provider-service" +import { MedusaError } from "../../../../core/utils/dist/common" + +type InjectedDependencies = { + analyticsProviderService: AnalyticsProviderService +} + +export default class AnalyticsService { + protected readonly analyticsProviderService_: AnalyticsProviderService + + constructor({ analyticsProviderService }: InjectedDependencies) { + this.analyticsProviderService_ = analyticsProviderService + } + + __hooks = { + onApplicationShutdown: async () => { + await this.analyticsProviderService_.shutdown() + }, + } + + getProvider() { + return this.analyticsProviderService_ + } + + async track(data: TrackAnalyticsEventDTO): Promise { + try { + await this.analyticsProviderService_.track(data) + } catch (error) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Error tracking event for ${data.event}: ${error.message}` + ) + } + } + + async identify(data: IdentifyAnalyticsEventDTO): Promise { + try { + await this.analyticsProviderService_.identify(data) + } catch (error) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Error identifying event for ${ + "group" in data ? data.group.id : data.actor_id + }: ${error.message}` + ) + } + } +} diff --git a/packages/modules/analytics/src/services/provider-service.ts b/packages/modules/analytics/src/services/provider-service.ts new file mode 100644 index 0000000000..ac372a42a2 --- /dev/null +++ b/packages/modules/analytics/src/services/provider-service.ts @@ -0,0 +1,56 @@ +import { MedusaError } from "@medusajs/framework/utils" +import { + Constructor, + IAnalyticsProvider, + ProviderIdentifyAnalyticsEventDTO, + ProviderTrackAnalyticsEventDTO, +} from "@medusajs/types" + +export const AnalyticsProviderIdentifierRegistrationName = + "analytics_providers_identifier" + +export const AnalyticsProviderRegistrationPrefix = "aly_" + +type InjectedDependencies = { + [ + key: `${typeof AnalyticsProviderRegistrationPrefix}${string}` + ]: IAnalyticsProvider +} + +export default class AnalyticsProviderService { + protected readonly analyticsProvider_: IAnalyticsProvider + + constructor(container: InjectedDependencies) { + const analyticsProviderKeys = Object.keys(container).filter((k) => + k.startsWith(AnalyticsProviderRegistrationPrefix) + ) + + if (analyticsProviderKeys.length !== 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Analytics module should be initialized with exactly one provider` + ) + } + + this.analyticsProvider_ = container[analyticsProviderKeys[0]] + } + + static getRegistrationIdentifier( + providerClass: Constructor, + optionName?: string + ) { + return `${(providerClass as any).identifier}_${optionName}` + } + + async track(data: ProviderTrackAnalyticsEventDTO): Promise { + this.analyticsProvider_.track(data) + } + + async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise { + this.analyticsProvider_.identify(data) + } + + async shutdown(): Promise { + await this.analyticsProvider_.shutdown?.() + } +} diff --git a/packages/modules/analytics/src/types/index.ts b/packages/modules/analytics/src/types/index.ts new file mode 100644 index 0000000000..3b1d90dbd5 --- /dev/null +++ b/packages/modules/analytics/src/types/index.ts @@ -0,0 +1,24 @@ +import { + ModuleProviderExports, + ModuleServiceInitializeOptions, +} from "@medusajs/framework/types" + +export type AnalyticsModuleOptions = Partial & { + /** + * Providers to be registered + */ + provider?: { + /** + * The module provider to be registered + */ + resolve: string | ModuleProviderExports + /** + * The id of the provider + */ + id: string + /** + * key value pair of the configuration to be passed to the provider constructor + */ + options?: Record + } +} diff --git a/packages/modules/analytics/tsconfig.json b/packages/modules/analytics/tsconfig.json new file mode 100644 index 0000000000..618ba37b1d --- /dev/null +++ b/packages/modules/analytics/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../_tsconfig.base.json", + "compilerOptions": { + "paths": { + "@services": ["./src/services"], + "@types": ["./src/types"] + } + } +} diff --git a/packages/modules/providers/analytics-local/.gitignore b/packages/modules/providers/analytics-local/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/analytics-local/CHANGELOG.md b/packages/modules/providers/analytics-local/CHANGELOG.md new file mode 100644 index 0000000000..237cdc12a2 --- /dev/null +++ b/packages/modules/providers/analytics-local/CHANGELOG.md @@ -0,0 +1 @@ +# @medusajs/analytics-local diff --git a/packages/modules/providers/analytics-local/README.md b/packages/modules/providers/analytics-local/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/analytics-local/jest.config.js b/packages/modules/providers/analytics-local/jest.config.js new file mode 100644 index 0000000000..189f374fd7 --- /dev/null +++ b/packages/modules/providers/analytics-local/jest.config.js @@ -0,0 +1,6 @@ +const defineJestConfig = require("../../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@services": "/src/services", + }, +}) diff --git a/packages/modules/providers/analytics-local/package.json b/packages/modules/providers/analytics-local/package.json new file mode 100644 index 0000000000..6dde7dc6e4 --- /dev/null +++ b/packages/modules/providers/analytics-local/package.json @@ -0,0 +1,42 @@ +{ + "name": "@medusajs/analytics-local", + "version": "2.8.2", + "description": "Local analytics provider for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/providers/analytics-local" + }, + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], + "engines": { + "node": ">=20" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "test": "jest --passWithNoTests src", + "build": "rimraf dist && tsc --build ./tsconfig.json", + "watch": "tsc --watch" + }, + "devDependencies": { + "@medusajs/framework": "2.8.2", + "@swc/core": "^1.7.28", + "@swc/jest": "^0.2.36", + "jest": "^29.7.0", + "rimraf": "^5.0.1", + "typescript": "^5.6.2" + }, + "peerDependencies": { + "@medusajs/framework": "2.8.2" + }, + "keywords": [ + "medusa-plugin", + "medusa-plugin-analytics" + ] +} diff --git a/packages/modules/providers/analytics-local/src/index.ts b/packages/modules/providers/analytics-local/src/index.ts new file mode 100644 index 0000000000..6868316737 --- /dev/null +++ b/packages/modules/providers/analytics-local/src/index.ts @@ -0,0 +1,8 @@ +import { ModuleProvider, Modules } from "@medusajs/framework/utils" +import { LocalAnalyticsService } from "./services/local-analytics" + +const services = [LocalAnalyticsService] + +export default ModuleProvider(Modules.ANALYTICS, { + services, +}) diff --git a/packages/modules/providers/analytics-local/src/services/local-analytics.ts b/packages/modules/providers/analytics-local/src/services/local-analytics.ts new file mode 100644 index 0000000000..d759f0d548 --- /dev/null +++ b/packages/modules/providers/analytics-local/src/services/local-analytics.ts @@ -0,0 +1,44 @@ +import { + LocalAnalyticsServiceOptions, + Logger, + ProviderIdentifyAnalyticsEventDTO, + ProviderTrackAnalyticsEventDTO, +} from "@medusajs/framework/types" +import { AbstractAnalyticsProviderService } from "@medusajs/framework/utils" + +type InjectedDependencies = { + logger: Logger +} + +export class LocalAnalyticsService extends AbstractAnalyticsProviderService { + static identifier = "analytics-local" + protected config_: LocalAnalyticsServiceOptions + protected logger_: Logger + + constructor( + { logger }: InjectedDependencies, + options: LocalAnalyticsServiceOptions + ) { + super() + this.config_ = options + this.logger_ = logger + } + + async track(data: ProviderTrackAnalyticsEventDTO): Promise { + this.logger_.debug( + `Tracking event: '${data.event}', actor_id: '${ + data.actor_id ?? "-" + }', group: '${data.group?.id ?? "-"}', properties: '${JSON.stringify( + data.properties + )}'` + ) + } + + async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise { + this.logger_.debug( + `Identifying user: '${data.actor_id ?? "-"}', group: '${ + "group" in data ? data.group.id : "-" + }', properties: '${JSON.stringify(data.properties)}'` + ) + } +} diff --git a/packages/modules/providers/analytics-local/tsconfig.json b/packages/modules/providers/analytics-local/tsconfig.json new file mode 100644 index 0000000000..7b8fdd0147 --- /dev/null +++ b/packages/modules/providers/analytics-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../_tsconfig.base.json", + "compilerOptions": { + "paths": { + "@services": ["./src/services"] + } + } +} diff --git a/packages/modules/providers/analytics-posthog/.gitignore b/packages/modules/providers/analytics-posthog/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/analytics-posthog/CHANGELOG.md b/packages/modules/providers/analytics-posthog/CHANGELOG.md new file mode 100644 index 0000000000..49b166713a --- /dev/null +++ b/packages/modules/providers/analytics-posthog/CHANGELOG.md @@ -0,0 +1 @@ +# @medusajs/analytics-posthog diff --git a/packages/modules/providers/analytics-posthog/README.md b/packages/modules/providers/analytics-posthog/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/analytics-posthog/jest.config.js b/packages/modules/providers/analytics-posthog/jest.config.js new file mode 100644 index 0000000000..189f374fd7 --- /dev/null +++ b/packages/modules/providers/analytics-posthog/jest.config.js @@ -0,0 +1,6 @@ +const defineJestConfig = require("../../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@services": "/src/services", + }, +}) diff --git a/packages/modules/providers/analytics-posthog/package.json b/packages/modules/providers/analytics-posthog/package.json new file mode 100644 index 0000000000..cf38d2e0aa --- /dev/null +++ b/packages/modules/providers/analytics-posthog/package.json @@ -0,0 +1,43 @@ +{ + "name": "@medusajs/analytics-posthog", + "version": "2.8.2", + "description": "Posthog analytics provider for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/providers/analytics-posthog" + }, + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], + "engines": { + "node": ">=20" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "test": "jest --passWithNoTests src", + "build": "rimraf dist && tsc --build ./tsconfig.json", + "watch": "tsc --watch" + }, + "devDependencies": { + "@medusajs/framework": "2.8.2", + "@swc/core": "^1.7.28", + "@swc/jest": "^0.2.36", + "jest": "^29.7.0", + "posthog-node": "^4.17.1", + "rimraf": "^5.0.1", + "typescript": "^5.6.2" + }, + "peerDependencies": { + "@medusajs/framework": "2.8.2" + }, + "keywords": [ + "medusa-plugin", + "medusa-plugin-analytics" + ] +} diff --git a/packages/modules/providers/analytics-posthog/src/index.ts b/packages/modules/providers/analytics-posthog/src/index.ts new file mode 100644 index 0000000000..87413bb33c --- /dev/null +++ b/packages/modules/providers/analytics-posthog/src/index.ts @@ -0,0 +1,8 @@ +import { ModuleProvider, Modules } from "@medusajs/framework/utils" +import { PosthogAnalyticsService } from "./services/posthog-analytics" + +const services = [PosthogAnalyticsService] + +export default ModuleProvider(Modules.ANALYTICS, { + services, +}) diff --git a/packages/modules/providers/analytics-posthog/src/services/posthog-analytics.ts b/packages/modules/providers/analytics-posthog/src/services/posthog-analytics.ts new file mode 100644 index 0000000000..95e1824f15 --- /dev/null +++ b/packages/modules/providers/analytics-posthog/src/services/posthog-analytics.ts @@ -0,0 +1,89 @@ +import { + PosthogAnalyticsServiceOptions, + Logger, + ProviderIdentifyAnalyticsEventDTO, + ProviderTrackAnalyticsEventDTO, +} from "@medusajs/framework/types" +import { PostHog } from "posthog-node" +import { AbstractAnalyticsProviderService } from "@medusajs/framework/utils" + +type InjectedDependencies = { + logger: Logger +} + +export class PosthogAnalyticsService extends AbstractAnalyticsProviderService { + static identifier = "analytics-posthog" + protected config_: PosthogAnalyticsServiceOptions + protected logger_: Logger + protected client_: PostHog + + constructor( + { logger }: InjectedDependencies, + options: PosthogAnalyticsServiceOptions + ) { + super() + this.config_ = options + this.logger_ = logger + + if (!options.posthogEventsKey) { + throw new Error("Posthog API key is not set, but is required") + } + + this.client_ = new PostHog(options.posthogEventsKey, { + host: options.posthogHost || "https://eu.i.posthog.com", + }) + } + + async track(data: ProviderTrackAnalyticsEventDTO): Promise { + if (!data.event) { + throw new Error( + "Event name is required when tracking an event with Posthog" + ) + } + + if (!data.actor_id) { + throw new Error( + "Actor ID is required when tracking an event with Posthog" + ) + } + + if (data.group?.id && !data.group?.type) { + throw new Error( + "Group type is required if passing group id when tracking an event with Posthog" + ) + } + + this.client_.capture({ + event: data.event, + distinctId: data.actor_id, + properties: data.properties, + groups: data.group?.id + ? { [data.group.type!]: data.group.id } + : undefined, + }) + } + + async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise { + if ("group" in data) { + this.client_.groupIdentify({ + groupKey: data.group.id!, + groupType: data.group.type!, + properties: data.properties, + distinctId: data.actor_id, + }) + } else if (data.actor_id) { + this.client_.identify({ + distinctId: data.actor_id, + properties: data.properties, + }) + } + + throw new Error( + "Actor or group is required when identifying an entity with Posthog" + ) + } + + async shutdown() { + await this.client_.shutdown() + } +} diff --git a/packages/modules/providers/analytics-posthog/tsconfig.json b/packages/modules/providers/analytics-posthog/tsconfig.json new file mode 100644 index 0000000000..7b8fdd0147 --- /dev/null +++ b/packages/modules/providers/analytics-posthog/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../_tsconfig.base.json", + "compilerOptions": { + "paths": { + "@services": ["./src/services"] + } + } +} diff --git a/yarn.lock b/yarn.lock index bd32b74bff..7cce2a74f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5820,6 +5820,55 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/analytics-local@2.8.2, @medusajs/analytics-local@workspace:packages/modules/providers/analytics-local": + version: 0.0.0-use.local + resolution: "@medusajs/analytics-local@workspace:packages/modules/providers/analytics-local" + dependencies: + "@medusajs/framework": 2.8.2 + "@swc/core": ^1.7.28 + "@swc/jest": ^0.2.36 + jest: ^29.7.0 + rimraf: ^5.0.1 + typescript: ^5.6.2 + peerDependencies: + "@medusajs/framework": 2.8.2 + languageName: unknown + linkType: soft + +"@medusajs/analytics-posthog@2.8.2, @medusajs/analytics-posthog@workspace:packages/modules/providers/analytics-posthog": + version: 0.0.0-use.local + resolution: "@medusajs/analytics-posthog@workspace:packages/modules/providers/analytics-posthog" + dependencies: + "@medusajs/framework": 2.8.2 + "@swc/core": ^1.7.28 + "@swc/jest": ^0.2.36 + jest: ^29.7.0 + posthog-node: ^4.17.1 + rimraf: ^5.0.1 + typescript: ^5.6.2 + peerDependencies: + "@medusajs/framework": 2.8.2 + languageName: unknown + linkType: soft + +"@medusajs/analytics@2.8.2, @medusajs/analytics@workspace:packages/modules/analytics": + version: 0.0.0-use.local + resolution: "@medusajs/analytics@workspace:packages/modules/analytics" + dependencies: + "@medusajs/framework": 2.8.2 + "@medusajs/test-utils": 2.8.2 + "@swc/core": ^1.7.28 + "@swc/jest": ^0.2.36 + jest: ^29.7.0 + rimraf: ^3.0.2 + tsc-alias: ^1.8.6 + typescript: ^5.6.2 + peerDependencies: + "@medusajs/framework": 2.8.2 + awilix: ^8.0.1 + languageName: unknown + linkType: soft + "@medusajs/api-key@2.8.2, @medusajs/api-key@workspace:^, @medusajs/api-key@workspace:packages/modules/api-key": version: 0.0.0-use.local resolution: "@medusajs/api-key@workspace:packages/modules/api-key" @@ -6583,6 +6632,9 @@ __metadata: "@inquirer/checkbox": ^2.3.11 "@inquirer/input": ^2.2.9 "@medusajs/admin-bundler": 2.8.2 + "@medusajs/analytics": 2.8.2 + "@medusajs/analytics-local": 2.8.2 + "@medusajs/analytics-posthog": 2.8.2 "@medusajs/api-key": 2.8.2 "@medusajs/auth": 2.8.2 "@medusajs/auth-emailpass": 2.8.2 @@ -16980,6 +17032,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.8.2": + version: 1.9.0 + resolution: "axios@npm:1.9.0" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: 9371a56886c2e43e4ff5647b5c2c3c046ed0a3d13482ef1d0135b994a628c41fbad459796f101c655e62f0c161d03883454474d2e435b2e021b1924d9f24994c + languageName: node + linkType: hard + "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -29039,6 +29102,15 @@ __metadata: languageName: node linkType: hard +"posthog-node@npm:^4.17.1": + version: 4.17.1 + resolution: "posthog-node@npm:4.17.1" + dependencies: + axios: ^1.8.2 + checksum: 31892ae0f03d28039f8081743b61f29dfb4479c0ff2e513a9e335d7872b869d2a30779c99e605a2482c99c7083b9b2bca81d2c3e3bf1d51fc3517de0335dfd52 + languageName: node + linkType: hard + "preferred-pm@npm:^3.0.0": version: 3.1.3 resolution: "preferred-pm@npm:3.1.3"