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
5 changes: 3 additions & 2 deletions backend/apps/cloud/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { UserFeedback } from '../user/entities/user-feedback.entity'
const ACTIVITY_EVENT_TYPES = [
'pageview',
'custom_event',
'outbound',
'error',
'captcha',
'performance',
Expand All @@ -38,7 +39,7 @@ const ACTIVITY_EVENT_TYPES = [
const CAPTCHA_PASS_CONDITION =
"type = 'captcha' AND if(indexOf(`meta.key`, 'captcha_event') = 0, 'pass', arrayElement(`meta.value`, indexOf(`meta.key`, 'captcha_event'))) = 'pass'"

const BILLABLE_COUNT_IF = `countIf(type IN ('pageview', 'custom_event', 'error') OR (${CAPTCHA_PASS_CONDITION}))`
const BILLABLE_COUNT_IF = `countIf(type IN ('pageview', 'custom_event', 'error', 'outbound') OR (${CAPTCHA_PASS_CONDITION}))`

const FREE_PLAN_CODES = [PlanCode.none, PlanCode.free, PlanCode.trial]

Expand Down Expand Up @@ -2137,7 +2138,7 @@ export class AdminService {
${BILLABLE_COUNT_IF} AS count
FROM events
WHERE pid IN ({pids:Array(FixedString(12))})
AND type IN ('pageview', 'custom_event', 'error', 'captcha')
AND type IN ('pageview', 'custom_event', 'error', 'captcha', 'outbound')
Comment thread
Blaumaus marked this conversation as resolved.
AND importID IS NULL
AND created >= {from:String}
GROUP BY pid
Expand Down
54 changes: 43 additions & 11 deletions backend/apps/cloud/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
GetErrorOverviewOptions,
} from './dto/get-error-overview.dto'
import { PatchStatusDto } from './dto/patch-status.dto'
import { OutboundDto } from './dto/outbound.dto'
import { eventTransformer } from './utils/transformers'
import { enrichTrafficSource } from './utils/clickIdSources'
import {
Expand Down Expand Up @@ -1775,6 +1776,35 @@ export class AnalyticsController {
@Headers() headers,
@Ip() reqIP,
) {
return this.logInteraction(eventsDTO, headers, reqIP)
}

@Post('outbound')
@ApiKeyCollection()
@ApiKeyAccess('events:write', 'body', 'pid')
@Public()
async logOutbound(
@Body() outboundDTO: OutboundDto,
@Headers() headers,
@Ip() reqIP,
) {
return this.logInteraction(
{ ...outboundDTO, ev: '', unique: false },
headers,
reqIP,
outboundDTO.url,
outboundDTO.title,
)
}

private async logInteraction(
eventsDTO: EventsDto,
headers,
reqIP,
outboundUrl?: string,
outboundTitle?: string | null,
) {
const isOutbound = outboundUrl !== undefined
const { 'user-agent': userAgent, origin } = headers

const ip = getIPFromHeaders(headers) || reqIP || ''
Expand All @@ -1795,7 +1825,7 @@ export class AnalyticsController {

const project = await this.analyticsService.validate(eventsDTO, origin, ip)

if (eventsDTO.unique) {
if (!isOutbound && eventsDTO.unique) {
const [unique] = await this.analyticsService.generateAndStoreSessionId(
`${eventsDTO.pid}-${eventsDTO.ev}`,
userAgent,
Expand Down Expand Up @@ -1823,7 +1853,7 @@ export class AnalyticsController {
this.analyticsService.checkCountryBlacklist(project, country)

this.logger.log(
`pid: ${eventsDTO.pid}, ev: ${eventsDTO.ev}, pg: ${eventsDTO.pg}`,
`pid: ${eventsDTO.pid}, event: ${isOutbound ? outboundUrl : eventsDTO.ev}, pg: ${eventsDTO.pg}`,
'POST /analytics/custom',
)

Expand Down Expand Up @@ -1855,13 +1885,14 @@ export class AnalyticsController {
enrichTrafficSource(eventsDTO)

const transformed = eventTransformer({
type: 'custom_event',
...(isOutbound
? { type: 'outbound' as const, url: outboundUrl, title: outboundTitle }
: { type: 'custom_event' as const, ev: eventsDTO.ev }),
psid,
sid,
profileId,
pid: eventsDTO.pid,
host: this.analyticsService.getHostFromOrigin(headers.origin),
ev: eventsDTO.ev,
pg: eventsDTO.pg,
dv: deviceType,
br: browserName,
Expand Down Expand Up @@ -1894,16 +1925,17 @@ export class AnalyticsController {
clickhouse_settings: { async_insert: 1 },
})

void this.trackCustomEventExperimentExposures(
eventsDTO.pid,
eventsDTO.ev,
profileId,
transformed.created,
)
if (!isOutbound)
void this.trackCustomEventExperimentExposures(
eventsDTO.pid,
eventsDTO.ev,
profileId,
transformed.created,
)
} catch (reason) {
this.logger.error(reason)
throw new InternalServerErrorException(
'Error occured while saving the custom event',
'Error occurred while saving the event',
)
}

Expand Down
1 change: 1 addition & 0 deletions backend/apps/cloud/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ export enum DataType {

type AnalyticsEventType = 'pageview' | 'custom_event'
type EventsAllTimeType =
| 'outbound'
| AnalyticsEventType
| 'performance'
| 'error'
Expand Down
1 change: 1 addition & 0 deletions backend/apps/cloud/src/analytics/dto/data-deletion.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { PID_REGEX } from '../../common/constants'
export const DATA_DELETION_EVENT_TYPES = [
'pageview',
'custom_event',
'outbound',
'error',
'performance',
'captcha',
Expand Down
24 changes: 24 additions & 0 deletions backend/apps/cloud/src/analytics/dto/outbound.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { OmitType, ApiProperty } from '@nestjs/swagger'
import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'
import { EventsDto } from './events.dto'

export class OutboundDto extends OmitType(EventsDto, [
'ev',
'unique',
] as const) {
@ApiProperty({ description: 'Outbound destination URL', maxLength: 2048 })
@IsString()
@MaxLength(2048)
@IsUrl({
protocols: ['http', 'https'],
require_protocol: true,
require_tld: false,
disallow_auth: true,
})
url: string

@IsOptional()
@IsString()
@MaxLength(2048)
title?: string | null
}
17 changes: 17 additions & 0 deletions backend/apps/cloud/src/analytics/utils/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ interface CustomEventOptions extends CommonOptions {
ev: string
}

interface OutboundEventOptions extends CommonOptions {
type: 'outbound'
url: string
title?: string | null
}

interface ErrorEventOptions extends CommonOptions {
type: 'error'
eid: string
Expand Down Expand Up @@ -103,6 +109,7 @@ interface CaptchaOptions {
type EventTransformerOptions =
| PageviewOptions
| CustomEventOptions
| OutboundEventOptions
| ErrorEventOptions
| PerformanceOptions
| CaptchaOptions
Expand Down Expand Up @@ -162,6 +169,16 @@ export const eventTransformer = (opts: EventTransformerOptions) => {
}
}

if (opts.type === 'outbound') {
return {
type: 'outbound' as const,
...buildCommon(opts),
outbound_url: opts.url,
title: opts.title || null,
created,
}
}

if (opts.type === 'error') {
return {
type: 'error' as const,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { AnalyticsController } from '../../analytics.controller'
import { EventsDto } from '../../dto/events.dto'
import { OutboundDto } from '../../dto/outbound.dto'
import { clickhouse } from '../../../common/integrations/clickhouse'

jest.mock('../../../common/integrations/clickhouse', () => ({
clickhouse: { insert: jest.fn().mockResolvedValue(undefined) },
}))
jest.mock('../../../common/utils', () => ({
deriveKey: () => 'test-secret',
getIPFromHeaders: () => '192.0.2.1',
getIPDetails: () => ({ country: 'GB' }),
}))

describe('Outbound ingestion', () => {
let controller: AnalyticsController
let analytics: Record<string, jest.Mock>
let exposures: jest.Mock

beforeEach(() => {
jest.clearAllMocks()
analytics = {
checkBot: jest.fn().mockResolvedValue({ isBot: false }),
validate: jest.fn().mockResolvedValue({}),
checkCountryBlacklist: jest.fn(),
getRequestInformation: jest
.fn()
.mockResolvedValue({ browserName: 'Firefox' }),
generateAndStoreSessionId: jest
.fn()
.mockResolvedValue([true, '123', '456']),
generateProfileId: jest.fn().mockResolvedValue('profile'),
recordSessionActivity: jest.fn(),
getHostFromOrigin: jest.fn().mockReturnValue('example.org'),
}
exposures = jest.fn()
controller = Object.assign(Object.create(AnalyticsController.prototype), {
analyticsService: analytics,
logger: { log: jest.fn(), error: jest.fn() },
trackCustomEventExperimentExposures: exposures,
})
})

it('stores one native event with context without custom-event exposure', async () => {
await controller.logOutbound(
{
pid: 'testproject1',
url: 'https://tickets.example.org',
pg: '/article',
title: 'Article',
} as OutboundDto,
{ origin: 'https://example.org' },
'',
)
expect(clickhouse.insert).toHaveBeenCalledWith(
expect.objectContaining({
values: [
expect.objectContaining({
type: 'outbound',
outbound_url: 'https://tickets.example.org',
title: 'Article',
pg: '/article',
host: 'example.org',
psid: '123',
sid: '456',
}),
],
}),
)
expect(exposures).not.toHaveBeenCalled()
expect(analytics.recordSessionActivity).toHaveBeenCalled()
})

it('does not let an extra URL field change a custom event into outbound', async () => {
await controller.logCustom(
{
pid: 'testproject1',
ev: 'signup',
url: 'javascript:alert(1)',
} as unknown as EventsDto,
{},
'',
)
expect(clickhouse.insert).toHaveBeenCalledWith(
expect.objectContaining({
values: [
expect.objectContaining({
type: 'custom_event',
event_name: 'signup',
}),
],
}),
)
expect(exposures).toHaveBeenCalledWith(
'testproject1',
'signup',
'profile',
expect.any(String),
)
})

it('applies bot and project validation to outbound clicks', async () => {
analytics.checkBot.mockResolvedValueOnce({ isBot: true })
await controller.logOutbound(
{ pid: 'testproject1', url: 'https://example.org' } as OutboundDto,
{},
'',
)
expect(clickhouse.insert).not.toHaveBeenCalled()
analytics.validate.mockRejectedValueOnce(new Error('Project disabled'))
await expect(
controller.logOutbound(
{ pid: 'testproject1', url: 'https://example.org' } as OutboundDto,
{},
'',
),
).rejects.toThrow('Project disabled')
expect(clickhouse.insert).not.toHaveBeenCalled()
})
})
Loading
Loading