Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Cloud vs Community Edition
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Infrastructure management** | ✅ It's easy to get started with Swetrix Cloud - you can set up tracking in a matter of minutes. We manage server maintenance, upgrades, security for you. | ⚠️ You are responsible for managing servers, installs, upgrades, scaling and backups. |
| **Core analytics (traffic, events, sessions, funnels, performance, errors)** | ✅ Included | ✅ Included |
| **Advanced features (Revenue, Experiments, AI)** | ✅ Included | ⚠️ Not included |
| **Advanced features (Revenue, Experiments, AI)** | ✅ Included | ⚠️ Cloud only; not included in CE |
| **Teams & sharing** | ✅ Organisations to manage multiple projects and users with permissions setup; invite people to your projects directly, or share a public or password protected link with people. | ⚠️ Only direct project invites, password protected links and public projects are supported. |
| **Alerts & notifications** | ✅ Yes (Email, Slack, Telegram, Discord, webhook, web push) | ⚠️ Not included |
| **Email reports** | ✅ Yes (weekly/monthly/quarterly) | ⚠️ Not included |
Expand Down
83 changes: 83 additions & 0 deletions backend/apps/community/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import { LiveVisitorsDto } from './dto/live-visitors.dto'
import { NoscriptDto } from './dto/noscript.dto'
import { GetKeywordsDto } from './dto/get-keywords.dto'
import { GSCService } from '../project/gsc.service'
import { ExperimentService } from '../experiment/experiment.service'
import { getExperimentVariant } from '../feature-flag/evaluation'

dayjs.extend(utc)
dayjs.extend(dayjsTimezone)
Expand Down Expand Up @@ -192,6 +194,7 @@ export class AnalyticsController {
private readonly analyticsService: AnalyticsService,
private readonly logger: AppLoggerService,
private readonly gscService: GSCService,
private readonly experimentService: ExperimentService,
) {}

@Get()
Expand Down Expand Up @@ -1168,6 +1171,13 @@ export class AnalyticsController {
values: [transformed],
clickhouse_settings: { async_insert: 1 },
})

void this.trackCustomEventExperimentExposures(
eventsDTO.pid,
eventsDTO.ev,
profileId,
transformed.created,
)
} catch (e) {
this.logger.error(e)
throw new InternalServerErrorException(
Expand All @@ -1178,6 +1188,79 @@ export class AnalyticsController {
return {}
}

private async trackCustomEventExperimentExposures(
pid: string,
eventName: string,
profileId: string,
created: string,
) {
try {
const experiments =
await this.experimentService.findRunningCustomEventExperiments(
pid,
eventName,
)

if (_isEmpty(experiments)) {
return
}

const exposures = []
for (const experiment of experiments) {
if (!experiment.variants || experiment.variants.length === 0) {
continue
}

const sortedVariants = [...experiment.variants].sort((a, b) =>
a.key.localeCompare(b.key),
)
const variantKey = getExperimentVariant(
experiment.id,
sortedVariants.map((variant) => ({
key: variant.key,
rolloutPercentage: variant.rolloutPercentage,
})),
profileId,
)

if (!variantKey) {
continue
}

exposures.push({
pid,
experimentId: experiment.id,
variantKey,
profileId,
created,
})
}

if (_isEmpty(exposures)) {
return
}

clickhouse
.insert({
table: 'experiment_exposures',
values: exposures,
format: 'JSONEachRow',
clickhouse_settings: { async_insert: 1 },
})
.catch((reason) => {
this.logger.warn(
{ reason },
'Failed to async insert custom event experiment exposures',
)
})
} catch (reason) {
this.logger.warn(
{ reason },
'Failed to track custom event experiment exposures',
)
}
}

@Post('hb')
@Auth(true, true)
async heartbeat(
Expand Down
5 changes: 3 additions & 2 deletions backend/apps/community/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'
import { Module, forwardRef } from '@nestjs/common'

import { AnalyticsService } from './analytics.service'
import { AnalyticsController } from './analytics.controller'
Expand All @@ -7,9 +7,10 @@ import { HeartbeatGateway } from './heartbeat.gateway'
import { SaltService } from './salt.service'
import { AppLoggerModule } from '../logger/logger.module'
import { ProjectModule } from '../project/project.module'
import { ExperimentModule } from '../experiment/experiment.module'

@Module({
imports: [AppLoggerModule, ProjectModule],
imports: [AppLoggerModule, ProjectModule, forwardRef(() => ExperimentModule)],
providers: [
AnalyticsService,
BotDetectionService,
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/community/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TaskManagerModule } from './task-manager/task-manager.module'
import { PingModule } from './ping/ping.module'
import { GoalModule } from './goal/goal.module'
import { FeatureFlagModule } from './feature-flag/feature-flag.module'
import { ExperimentModule } from './experiment/experiment.module'
import { CaptchaModule } from './captcha/captcha.module'
import { getI18nConfig } from './configs'
import { AuthModule } from './auth/auth.module'
Expand Down Expand Up @@ -77,6 +78,7 @@ const modules = [
AnalyticsModule,
PingModule,
GoalModule,
ExperimentModule,
FeatureFlagModule,
CaptchaModule,
AuthModule,
Expand Down
143 changes: 143 additions & 0 deletions backend/apps/community/src/experiment/bayesian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
interface VariantData {
key: string
exposures: number
conversions: number
}

type RandomFn = () => number

function mulberry32(seed: number): RandomFn {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}

function fnv1a32(input: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return hash >>> 0
}

function seedFromVariants(
variants: VariantData[],
simulations: number,
): number {
const normalized = [...variants]
.sort((a, b) => a.key.localeCompare(b.key))
.map((v) => `${v.key}:${v.exposures}:${v.conversions}`)
.join('|')
return (fnv1a32(normalized) ^ (simulations >>> 0)) >>> 0
}

function sampleGamma(shape: number, random: RandomFn): number {
if (shape < 1) {
throw new RangeError('sampleGamma expects shape >= 1')
}

const d = shape - 1 / 3
const c = 1 / Math.sqrt(9 * d)

while (true) {
let x: number
let v: number

do {
const u1 = Math.max(random(), Number.EPSILON)
const u2 = random()
x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
v = 1 + c * x
} while (v <= 0)

v = v * v * v
const u = random()

if (u < 1 - 0.0331 * x * x * x * x) {
return d * v
}

if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) {
return d * v
}
}
}

function sampleBeta(alpha: number, beta: number, random: RandomFn): number {
const x = sampleGamma(alpha, random)
const y = sampleGamma(beta, random)
return x / (x + y)
}

export function calculateBayesianProbabilities(
variants: VariantData[],
simulations: number = 10000,
): Map<string, number> {
if (variants.length === 0) {
return new Map()
}

simulations = Math.max(1, Math.floor(simulations))

const sanitizedVariants = variants.map((variant) => {
const exposures = Math.max(0, Math.floor(variant.exposures))
const conversions = Math.min(
Math.max(0, Math.floor(variant.conversions)),
exposures,
)
return { key: variant.key, exposures, conversions }
})

if (sanitizedVariants.length === 1) {
return new Map([[sanitizedVariants[0].key, 1]])
}

const totalExposures = sanitizedVariants.reduce(
(sum, variant) => sum + variant.exposures,
0,
)
if (totalExposures === 0) {
const probability = 1 / sanitizedVariants.length
return new Map(
sanitizedVariants.map((variant) => [variant.key, probability]),
)
}

const wins = new Map<string, number>()
for (const variant of sanitizedVariants) {
wins.set(variant.key, 0)
}

const random = mulberry32(seedFromVariants(sanitizedVariants, simulations))

for (let i = 0; i < simulations; i++) {
let bestRate = -1
let bestKey = ''

for (const variant of sanitizedVariants) {
const alpha = variant.conversions + 1
const beta = Math.max(1, variant.exposures - variant.conversions + 1)
const rate = sampleBeta(alpha, beta, random)

if (rate > bestRate) {
bestRate = rate
bestKey = variant.key
}
}

wins.set(bestKey, (wins.get(bestKey) || 0) + 1)
}

const probabilities = new Map<string, number>()
for (const [key, winCount] of wins) {
probabilities.set(key, winCount / simulations)
}

return probabilities
}
Loading
Loading