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:
co-authored by
Adrien de Peretti
Oli Juhl
parent
52bd9f9a53
commit
b9a51e217d
@@ -0,0 +1,6 @@
|
||||
/dist
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
.env
|
||||
*.sql
|
||||
@@ -0,0 +1 @@
|
||||
# @medusajs/analytics
|
||||
@@ -0,0 +1 @@
|
||||
# Analytics Module
|
||||
+23
@@ -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",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../_tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@services": ["./src/services"],
|
||||
"@types": ["./src/types"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user