feat: Add an analytics module and local and posthog providers (#12505)

* feat: Add an analytics module and local and posthog providers

* fix: Add tests and wire up in missing places

* fix: Address feedback and add missing module typing

* fix: Address feedback and add missing module typing

---------

Co-authored-by: Adrien de Peretti <adrien.deperetti@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Stevche Radevski
2025-05-19 19:57:13 +02:00
committed by GitHub
co-authored by Adrien de Peretti Oli Juhl
parent 52bd9f9a53
commit b9a51e217d
49 changed files with 1009 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/dist
node_modules
.DS_store
.env*
.env
*.sql
+1
View File
@@ -0,0 +1 @@
# @medusajs/analytics
+1
View File
@@ -0,0 +1 @@
# Analytics Module
@@ -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<void> {
return Promise.resolve()
}
async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise<void> {
return Promise.resolve()
}
async shutdown(): Promise<void> {
return Promise.resolve()
}
}
export const services = [AnalyticsProviderServiceFixtures]
@@ -0,0 +1 @@
export * from "./default-provider"
@@ -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<IAnalyticsModuleService>({
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",
},
})
})
})
},
})
+10
View File
@@ -0,0 +1,10 @@
const defineJestConfig = require("../../../define_jest_config")
module.exports = defineJestConfig({
moduleNameMapper: {
"^@models": "<rootDir>/src/models",
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/src/repositories",
"^@types": "<rootDir>/src/types",
"^@utils": "<rootDir>/src/utils",
},
})
+48
View File
@@ -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"
}
}
+8
View File
@@ -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],
})
@@ -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<void> => {
await moduleProviderLoader({
container,
providers: options?.providers || [],
registerServiceFn: registrationFn,
})
}
@@ -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<void> {
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<void> {
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}`
)
}
}
}
@@ -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<IAnalyticsProvider>,
optionName?: string
) {
return `${(providerClass as any).identifier}_${optionName}`
}
async track(data: ProviderTrackAnalyticsEventDTO): Promise<void> {
this.analyticsProvider_.track(data)
}
async identify(data: ProviderIdentifyAnalyticsEventDTO): Promise<void> {
this.analyticsProvider_.identify(data)
}
async shutdown(): Promise<void> {
await this.analyticsProvider_.shutdown?.()
}
}
@@ -0,0 +1,24 @@
import {
ModuleProviderExports,
ModuleServiceInitializeOptions,
} from "@medusajs/framework/types"
export type AnalyticsModuleOptions = Partial<ModuleServiceInitializeOptions> & {
/**
* 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<string, unknown>
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../_tsconfig.base.json",
"compilerOptions": {
"paths": {
"@services": ["./src/services"],
"@types": ["./src/types"]
}
}
}