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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ We've been building Swetrix since 2021 with a goal to make web analytics simple
- **Error tracking**: capture client‑side errors with details and aggregated views.
- **Shareable analytics**: public or password‑protected dashboards; invite teammates with roles, or manage access with organisations.
- **Data portability**: export to CSV and access data via our [developer API](https://docs.swetrix.com/statistics-api).
- **Alerts & notifications (Cloud)**: get notified on thresholds via Slack, Telegram or Discord.
- **Alerts & notifications (Cloud)**: get notified on thresholds via Email, Slack, Telegram, Discord, generic outbound webhook or browser web push, with per-alert custom message templates.
- **Feature flags**: manage feature rollouts and conduct safe releases.
- **Experiments (Cloud)**: run A/B tests and experiments to optimize your site.
- **Revenue analytics (Cloud)**: track MRR, churn and other financial metrics.
Expand Down Expand Up @@ -86,7 +86,7 @@ Cloud vs Community Edition
| **Core analytics (traffic, events, sessions, funnels, performance, errors)** | ✅ Included | ✅ Included |
| **Advanced features (Revenue, Experiments, AI)** | ✅ Included | ⚠️ Not included |
| **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 (Slack/Telegram/Discord) | ⚠️ Not included |
| **Alerts & notifications** | ✅ Yes (Email, Slack, Telegram, Discord, webhook, web push) | ⚠️ Not included |
| **Email reports** | ✅ Yes (weekly/monthly/quarterly) | ⚠️ Not included |
| **Geo analytics** | ✅ Swetrix Cloud uses premium GeoIP database to provide consistent and accurate country and city level geolocation data. | ⚠️ Less accurate, DB-IP City Lite Database; you need to pay for the full database if you want better accuracy. |
| **Release schedule** | ✅ Continuously developed with updates deployed as soon as they are ready | ℹ️ Periodic open‑source releases, latest features are not available immediately. |
Expand Down
9 changes: 8 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ ENABLE_TELEGRAM_INTEGRATION=false
# Telegram integration
TELEGRAM_BOT_TOKEN=''

# Web Push (VAPID) - required to enable browser push notifications channels.
# Generate a fresh keypair with: npx web-push generate-vapid-keys
# VAPID_SUBJECT must be a mailto: URI or HTTPS URL identifying you as the sender.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT='mailto:contact@swetrix.com'

# Paddle
PADDLE_VENDOR_ID=
PADDLE_API_KEY=
Expand Down Expand Up @@ -105,6 +112,6 @@ SWETRIX_PID=STEzHcB1rALV
# Shared secret the proxy.swetrix.org edge box sends in `X-Edge-Api-Key` to
# call the /v1/proxy-domain/allow and /v1/proxy-domain/active endpoints.
# Generate with: openssl rand -hex 32
# Leave blank if you don't run the managed reverse proxy edge the endpoints
# Leave blank if you don't run the managed reverse proxy edge - the endpoints
# fail closed (404) when this is unset.
MANAGED_PROXY_EDGE_API_KEY=
2 changes: 1 addition & 1 deletion backend/apps/cloud/src/ai/ai.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ export class AiController {
@Patch(':pid/chats/:chatId')
@Auth(false, true) // Allow optional auth for public projects
@ApiOperation({
summary: 'Update chat metadata (pinned, tags, name) owner only',
summary: 'Update chat metadata (pinned, tags, name) - owner only',
})
@ApiResponse({ status: 200, description: 'Chat metadata updated' })
async updateChatMeta(
Expand Down
62 changes: 58 additions & 4 deletions backend/apps/cloud/src/alert/alert.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ import {
import { AlertService } from './alert.service'
import { getIPFromHeaders } from '../common/utils'
import { trackCustom } from '../common/analytics'
import { NotificationChannelService } from '../notification-channel/notification-channel.service'
import {
DEFAULT_EMAIL_SUBJECT_TEMPLATE,
TemplateRendererService,
} from '../notification-channel/template-renderer.service'
import { NotificationChannelType } from '../notification-channel/entity/notification-channel.entity'

const ALERTS_MAXIMUM = ACCOUNT_PLANS[PlanCode.free].maxAlerts

Expand All @@ -52,8 +58,23 @@ export class AlertController {
private readonly projectService: ProjectService,
private readonly logger: AppLoggerService,
private readonly userService: UserService,
private readonly channelService: NotificationChannelService,
private readonly templateRenderer: TemplateRendererService,
) {}

@ApiBearerAuth()
@Get('/template-variables')
@Auth()
async getTemplateVariables(@Query('metric') metric: QueryMetric) {
if (!metric || !Object.values(QueryMetric).includes(metric)) {
throw new BadRequestException('Unknown metric')
}
return {
variables: this.templateRenderer.getVariablesForMetric(metric),
defaultTemplate: this.templateRenderer.getDefaultTemplate(metric),
}
}

@ApiBearerAuth()
@Get('/:alertId')
@Auth()
Expand All @@ -66,7 +87,7 @@ export class AlertController {

const alert = await this.alertService.findOne({
where: { id: alertId },
relations: ['project'],
relations: ['project', 'channels'],
})

if (_isEmpty(alert)) {
Expand Down Expand Up @@ -109,7 +130,7 @@ export class AlertController {
const result = await this.alertService.paginate(
{ take: safeTake, skip: safeSkip },
{ project: { id: projectId } },
['project'],
['project', 'channels'],
)

// @ts-expect-error
Expand Down Expand Up @@ -192,6 +213,21 @@ export class AlertController {
)
}

const channelIds = alertDTO.channelIds ?? []
const validatedChannels =
await this.channelService.validateChannelsForProject(
channelIds,
alertDTO.pid,
uid,
)

if (
validatedChannels.some((c) => c.type === NotificationChannelType.EMAIL) &&
!alertDTO.emailSubjectTemplate
) {
alertDTO.emailSubjectTemplate = DEFAULT_EMAIL_SUBJECT_TEMPLATE
}
Comment thread
Blaumaus marked this conversation as resolved.

try {
const alert: Partial<Alert> = {
name: alertDTO.name,
Expand All @@ -203,6 +239,9 @@ export class AlertController {
queryCustomEvent: alertDTO.queryCustomEvent ?? null,
alertOnNewErrorsOnly: alertDTO.alertOnNewErrorsOnly ?? true,
alertOnEveryCustomEvent: alertDTO.alertOnEveryCustomEvent ?? false,
messageTemplate: alertDTO.messageTemplate ?? null,
emailSubjectTemplate: alertDTO.emailSubjectTemplate ?? null,
channels: validatedChannels,
project,
}

Expand Down Expand Up @@ -290,6 +329,8 @@ export class AlertController {
'alertOnNewErrorsOnly',
'alertOnEveryCustomEvent',
'active',
'messageTemplate',
'emailSubjectTemplate',
]),
}

Expand Down Expand Up @@ -323,10 +364,23 @@ export class AlertController {

await this.alertService.update(
id,
_omit(updatePayload, ['project', 'lastTriggered']),
_omit(updatePayload, ['project', 'lastTriggered', 'channels']),
)

const updatedAlert = await this.alertService.findOne({ where: { id } })
if (Array.isArray(alertDTO.channelIds)) {
const validatedChannels =
await this.channelService.validateChannelsForProject(
alertDTO.channelIds,
alert.project.id,
uid,
)
await this.alertService.setChannels(id, validatedChannels)
}

const updatedAlert = await this.alertService.findOne({
where: { id },
relations: ['channels'],
})
if (!updatedAlert)
throw new NotFoundException('Alert not found after update')

Expand Down
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/alert/alert.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'
import { ProjectModule } from '../project/project.module'
import { AppLoggerModule } from '../logger/logger.module'
import { UserModule } from '../user/user.module'
import { NotificationChannelModule } from '../notification-channel/notification-channel.module'
import { AlertService } from './alert.service'
import { Alert } from './entity/alert.entity'
import { AlertController } from './alert.controller'
Expand All @@ -14,6 +15,7 @@ import { AlertController } from './alert.controller'
ProjectModule,
AppLoggerModule,
UserModule,
NotificationChannelModule,
],
providers: [AlertService],
exports: [AlertService],
Expand Down
20 changes: 18 additions & 2 deletions backend/apps/cloud/src/alert/alert.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FindManyOptions, FindOneOptions, Repository } from 'typeorm'
import { Pagination, PaginationOptionsInterface } from '../common/pagination'
import { Alert } from './entity/alert.entity'
import { AlertDTO } from './dto/alert.dto'
import { NotificationChannel } from '../notification-channel/entity/notification-channel.entity'

@Injectable()
export class AlertService {
Expand All @@ -24,7 +25,9 @@ export class AlertService {
order: {
name: 'ASC',
},
relations,
relations: relations
? [...new Set([...relations, 'channels'])]
: ['channels'],
})

return new Pagination<Alert>({
Expand All @@ -36,10 +39,23 @@ export class AlertService {
findOneWithRelations(id: string): Promise<Alert | null> {
return this.alertsReporsitory.findOne({
where: { id },
relations: ['project', 'project.admin'],
relations: ['project', 'project.admin', 'channels'],
})
}

async setChannels(
id: string,
channels: NotificationChannel[],
): Promise<void> {
const alert = await this.alertsReporsitory.findOne({
where: { id },
relations: ['channels'],
})
if (!alert) return
alert.channels = channels
await this.alertsReporsitory.save(alert)
}

async count(options: FindManyOptions<Alert> = {}): Promise<number> {
return this.alertsReporsitory.count(options)
}
Expand Down
30 changes: 30 additions & 0 deletions backend/apps/cloud/src/alert/dto/alert.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiProperty, PartialType } from '@nestjs/swagger'
import { Transform } from 'class-transformer'
import { PID_REGEX } from '../../common/constants'
import {
IsEnum,
Expand All @@ -9,6 +10,9 @@ import {
IsString,
IsNumber,
Matches,
IsArray,
IsUUID,
MaxLength,
} from 'class-validator'

export enum QueryMetric {
Expand All @@ -35,6 +39,12 @@ export enum QueryTime {
LAST_48_HOURS = 'last_48_hours',
}

const trimBlankToNull = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}

class AlertBaseDTO {
@ApiProperty()
@IsNotEmpty()
Expand Down Expand Up @@ -80,6 +90,26 @@ class AlertBaseDTO {
@IsBoolean()
@IsOptional()
alertOnEveryCustomEvent?: boolean

@ApiProperty({ type: [String] })
@IsArray()
@IsUUID('all', { each: true })
@IsOptional()
channelIds?: string[]

@ApiProperty({ nullable: true })
@Transform(trimBlankToNull)
@IsString()
@IsOptional()
@MaxLength(5000)
messageTemplate?: string | null

@ApiProperty({ nullable: true })
@Transform(trimBlankToNull)
@IsString()
@IsOptional()
@MaxLength(255)
emailSubjectTemplate?: string | null
Comment thread
Blaumaus marked this conversation as resolved.
}

export class CreateAlertDTO extends AlertBaseDTO {
Expand Down
26 changes: 25 additions & 1 deletion backend/apps/cloud/src/alert/entity/alert.entity.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
import {
Entity,
Column,
ManyToOne,
PrimaryGeneratedColumn,
ManyToMany,
JoinTable,
} from 'typeorm'
import { ApiProperty } from '@nestjs/swagger'

import { Project } from '../../project/entity/project.entity'
import { NotificationChannel } from '../../notification-channel/entity/notification-channel.entity'
import { QueryCondition, QueryMetric, QueryTime } from '../dto/alert.dto'

@Entity()
Expand Down Expand Up @@ -90,4 +98,20 @@ export class Alert {
default: null,
})
queryCustomEvent: string

@ApiProperty()
@Column('text', { nullable: true, default: null })
messageTemplate: string | null

@ApiProperty()
@Column('varchar', { length: 255, nullable: true, default: null })
emailSubjectTemplate: string | null

@ManyToMany(() => NotificationChannel, (channel) => channel.alerts)
@JoinTable({
name: 'alert_channels',
joinColumn: { name: 'alertId', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'channelId', referencedColumnName: 'id' },
})
channels: NotificationChannel[]
}
8 changes: 4 additions & 4 deletions backend/apps/cloud/src/analytics/utils/clickIdSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
* tracking parameter (a "click ID") to URLs they send users to. When the
* referring app is a native mobile app or in-app browser (Twitter/X,
* Reddit, Facebook, TikTok, Gmail iOS, Slack, ...) the HTTP `Referer`
* header is almost always stripped but the click ID is preserved in
* header is almost always stripped - but the click ID is preserved in
* the destination URL.
*
* By detecting these click IDs server-side we can synthesize a sensible
* `source` / `medium` (and a canonical `referrer`) for events that would
* otherwise be bucketed as "Direct / None".
*
* Explicit UTM parameters set by the marketer always win this only
* Explicit UTM parameters set by the marketer always win - this only
* fills in gaps.
*/

Expand Down Expand Up @@ -110,7 +110,7 @@ const CLICK_ID_MAP: Record<string, ClickIdMapping> = {

/**
* Iteration order is deterministic in modern engines (insertion order),
* which gives ad-network IDs priority over social/email matching the
* which gives ad-network IDs priority over social/email - matching the
* `CLICK_ID_MAP` declaration above.
*/
const CLICK_ID_KEYS = Object.keys(CLICK_ID_MAP)
Expand Down Expand Up @@ -187,7 +187,7 @@ interface TrafficSourceFields {
* source information derived from click IDs found in the URL.
*
* Mutates the passed object (and returns it) for ergonomic call-site
* usage. UTM parameters explicitly set by the marketer always win we
* usage. UTM parameters explicitly set by the marketer always win - we
* only fill empty fields.
*/
export const enrichTrafficSource = <T extends TrafficSourceFields>(
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { BullModule } from '@nestjs/bullmq'
import { ToolsModule } from './tools/tools.module'
import { PendingInvitationModule } from './pending-invitation/pending-invitation.module'
import { DataImportModule } from './data-import/data-import.module'
import { NotificationChannelModule } from './notification-channel/notification-channel.module'

const modules = [
SentryModule.forRoot(),
Expand Down Expand Up @@ -101,6 +102,7 @@ const modules = [
ToolsModule,
PendingInvitationModule,
DataImportModule,
NotificationChannelModule,
]

@Module({
Expand Down
Loading
Loading