From 91a86e24b4b0c27fecada7c6630428faaaa8c7d3 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 20 Oct 2026 19:16:23 +0100 Subject: [PATCH 1/2] Outbound link tracking --- backend/apps/cloud/src/admin/admin.service.ts | 5 +- .../src/analytics/analytics.controller.ts | 54 ++++-- .../cloud/src/analytics/analytics.service.ts | 1 + .../src/analytics/dto/data-deletion.dto.ts | 1 + .../cloud/src/analytics/dto/outbound.dto.ts | 24 +++ .../cloud/src/analytics/utils/transformers.ts | 17 ++ .../v2/__tests__/outbound-ingestion.spec.ts | 120 +++++++++++++ .../v2/__tests__/outbound-links.spec.ts | 146 +++++++++++++++ .../src/analytics/v2/analytics-v2.service.ts | 31 +++- .../v2/query/breakdown-query.builder.ts | 2 +- .../analytics/v2/query/filters.translator.ts | 2 +- .../src/analytics/v2/registry/dimensions.ts | 7 + .../src/analytics/v2/registry/metrics.ts | 7 + .../cloud/src/analytics/v2/registry/types.ts | 1 + .../src/data-import/data-import.service.ts | 2 +- .../data-import/mappers/mapper.interface.ts | 2 +- .../data-import/mappers/plausible.mapper.ts | 14 +- .../apps/cloud/src/project/plan-usage.spec.ts | 41 ++++- .../apps/cloud/src/project/project.service.ts | 32 +++- .../src/task-manager/task-manager.service.ts | 6 +- .../cloud/src/user/interfaces/usage-info.ts | 2 + .../apps/cloud/src/user/user.controller.ts | 4 + .../src/analytics/analytics.controller.ts | 54 ++++-- .../src/analytics/analytics.service.ts | 1 + .../src/analytics/dto/data-deletion.dto.ts | 1 + .../src/analytics/dto/outbound.dto.ts | 24 +++ .../src/analytics/utils/transformers.ts | 17 ++ .../v2/__tests__/outbound-ingestion.spec.ts | 120 +++++++++++++ .../v2/__tests__/outbound-links.spec.ts | 146 +++++++++++++++ .../analytics/v2/__tests__/registry.spec.ts | 6 +- .../src/analytics/v2/analytics-v2.service.ts | 31 +++- .../v2/query/breakdown-query.builder.ts | 2 +- .../analytics/v2/query/filters.translator.ts | 2 +- .../src/analytics/v2/registry/dimensions.ts | 7 + .../src/analytics/v2/registry/metrics.ts | 7 + .../src/analytics/v2/registry/types.ts | 1 + .../src/data-import/data-import.service.ts | 2 +- .../data-import/mappers/mapper.interface.ts | 2 +- .../data-import/mappers/plausible.mapper.ts | 14 +- .../community/src/project/project.service.ts | 4 +- .../clickhouse/2026_10_13_outbound_links.js | 5 + .../clickhouse/initialise_database.js | 2 + .../selfhosted_2026_10_13_outbound_links.js | 5 + backend/tests/data-import-mappers.spec.ts | 25 +++ docs/content/docs/api/events.mdx | 13 ++ docs/content/docs/api/stats-v2.mdx | 11 ++ docs/content/docs/data-import.mdx | 6 + docs/content/docs/script-reference.mdx | 42 +++++ packages/tracker-js/README.md | 13 ++ packages/tracker-js/src/Lib.ts | 69 ++++++- packages/tracker-js/src/index.ts | 10 +- packages/tracker-js/tests/outbound.test.ts | 170 ++++++++++++++++++ web/app/lib/models/Entry.ts | 1 + web/app/lib/models/Usageinfo.ts | 2 + .../Settings/components/DeleteDataModal.tsx | 6 + .../Project/View/ViewProject.helpers.tsx | 1 + .../pages/Project/View/v2/BreakdownPanel.tsx | 60 +++++-- .../Project/tabs/Traffic/TrafficView.tsx | 91 +++++++++- web/app/pages/UserSettings/UserSettings.tsx | 26 +++ web/app/routes/projects.settings.$id.tsx | 1 + web/app/ui/TrackingSetup.tsx | 44 +++-- web/public/locales/en.json | 7 + 62 files changed, 1466 insertions(+), 106 deletions(-) create mode 100644 backend/apps/cloud/src/analytics/dto/outbound.dto.ts create mode 100644 backend/apps/cloud/src/analytics/v2/__tests__/outbound-ingestion.spec.ts create mode 100644 backend/apps/cloud/src/analytics/v2/__tests__/outbound-links.spec.ts create mode 100644 backend/apps/community/src/analytics/dto/outbound.dto.ts create mode 100644 backend/apps/community/src/analytics/v2/__tests__/outbound-ingestion.spec.ts create mode 100644 backend/apps/community/src/analytics/v2/__tests__/outbound-links.spec.ts create mode 100644 backend/migrations/clickhouse/2026_10_13_outbound_links.js create mode 100644 backend/migrations/clickhouse/selfhosted_2026_10_13_outbound_links.js create mode 100644 packages/tracker-js/tests/outbound.test.ts diff --git a/backend/apps/cloud/src/admin/admin.service.ts b/backend/apps/cloud/src/admin/admin.service.ts index 96291e1a2..ce99d3bac 100644 --- a/backend/apps/cloud/src/admin/admin.service.ts +++ b/backend/apps/cloud/src/admin/admin.service.ts @@ -29,6 +29,7 @@ import { UserFeedback } from '../user/entities/user-feedback.entity' const ACTIVITY_EVENT_TYPES = [ 'pageview', 'custom_event', + 'outbound', 'error', 'captcha', 'performance', @@ -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] @@ -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') AND created >= {from:String} GROUP BY pid ` diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index e7aefa073..bbe3523df 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -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 { @@ -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 || '' @@ -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, @@ -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', ) @@ -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, @@ -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', ) } diff --git a/backend/apps/cloud/src/analytics/analytics.service.ts b/backend/apps/cloud/src/analytics/analytics.service.ts index fbeb9412e..f279ee7f4 100644 --- a/backend/apps/cloud/src/analytics/analytics.service.ts +++ b/backend/apps/cloud/src/analytics/analytics.service.ts @@ -727,6 +727,7 @@ export enum DataType { type AnalyticsEventType = 'pageview' | 'custom_event' type EventsAllTimeType = + | 'outbound' | AnalyticsEventType | 'performance' | 'error' diff --git a/backend/apps/cloud/src/analytics/dto/data-deletion.dto.ts b/backend/apps/cloud/src/analytics/dto/data-deletion.dto.ts index bef99385a..ae043b12d 100644 --- a/backend/apps/cloud/src/analytics/dto/data-deletion.dto.ts +++ b/backend/apps/cloud/src/analytics/dto/data-deletion.dto.ts @@ -13,6 +13,7 @@ import { PID_REGEX } from '../../common/constants' export const DATA_DELETION_EVENT_TYPES = [ 'pageview', 'custom_event', + 'outbound', 'error', 'performance', 'captcha', diff --git a/backend/apps/cloud/src/analytics/dto/outbound.dto.ts b/backend/apps/cloud/src/analytics/dto/outbound.dto.ts new file mode 100644 index 000000000..977c831c6 --- /dev/null +++ b/backend/apps/cloud/src/analytics/dto/outbound.dto.ts @@ -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 +} diff --git a/backend/apps/cloud/src/analytics/utils/transformers.ts b/backend/apps/cloud/src/analytics/utils/transformers.ts index bd1ef1e99..43686fb97 100644 --- a/backend/apps/cloud/src/analytics/utils/transformers.ts +++ b/backend/apps/cloud/src/analytics/utils/transformers.ts @@ -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 @@ -103,6 +109,7 @@ interface CaptchaOptions { type EventTransformerOptions = | PageviewOptions | CustomEventOptions + | OutboundEventOptions | ErrorEventOptions | PerformanceOptions | CaptchaOptions @@ -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, diff --git a/backend/apps/cloud/src/analytics/v2/__tests__/outbound-ingestion.spec.ts b/backend/apps/cloud/src/analytics/v2/__tests__/outbound-ingestion.spec.ts new file mode 100644 index 000000000..9313383ad --- /dev/null +++ b/backend/apps/cloud/src/analytics/v2/__tests__/outbound-ingestion.spec.ts @@ -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 + 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() + }) +}) diff --git a/backend/apps/cloud/src/analytics/v2/__tests__/outbound-links.spec.ts b/backend/apps/cloud/src/analytics/v2/__tests__/outbound-links.spec.ts new file mode 100644 index 000000000..a871ea124 --- /dev/null +++ b/backend/apps/cloud/src/analytics/v2/__tests__/outbound-links.spec.ts @@ -0,0 +1,146 @@ +import { plainToInstance } from 'class-transformer' +import { validate } from 'class-validator' +import { AnalyticsService } from '../../analytics.service' +import { AnalyticsV2Service } from '../analytics-v2.service' +import { OutboundDto } from '../../dto/outbound.dto' +import { eventTransformer } from '../../utils/transformers' +import { toV1FiltersJson } from '../query/filters.translator' +import { clickhouse } from '../../../common/integrations/clickhouse' + +jest.mock('../../../common/integrations/clickhouse', () => ({ + clickhouse: { query: jest.fn() }, +})) + +const pid = 'testproject1' + +describe('Outbound links', () => { + it('validates and stores a native outbound event with its source context', async () => { + const dto = plainToInstance(OutboundDto, { + pid, + url: 'https://tickets.example.org/event?id=4', + pg: '/article', + }) + expect(await validate(dto)).toEqual([]) + expect( + eventTransformer({ type: 'outbound', ...dto, psid: '123' }), + ).toMatchObject({ + type: 'outbound', + outbound_url: dto.url, + pg: '/article', + psid: '123', + }) + expect(eventTransformer({ type: 'outbound', ...dto })).not.toHaveProperty( + 'event_name', + ) + }) + + it.each([ + undefined, + null, + 123, + '', + '/relative', + 'javascript:alert(1)', + 'ftp://example.org', + 'https://user:secret@example.org', + `https://example.org/${'a'.repeat(2048)}`, + ])('rejects invalid destinations (%#)', async (url) => { + const errors = await validate(plainToInstance(OutboundDto, { pid, url })) + expect(errors.some((error) => error.property === 'url')).toBe(true) + }) + + it('groups clicks and unique visitors with bound filters and pagination', async () => { + const analytics = Object.create(AnalyticsService.prototype) + const service = new AnalyticsV2Service(analytics, {} as never) + jest.spyOn(service, 'resolveTimeframe').mockResolvedValue({ + period: 'all', + timeBucket: null, + allowedTimeBuckets: null, + safeTimezone: 'UTC', + groupFrom: '2026-01-01 00:00:00', + groupTo: '2026-10-13 23:59:59', + groupFromUTC: '2026-01-01 00:00:00', + groupToUTC: '2026-10-13 23:59:59', + }) + jest.mocked(clickhouse.query).mockResolvedValue({ + json: async () => ({ + data: [ + { + value: 'https://example.org', + clicks: '8', + visitors: '3', + __total: '2', + }, + { value: null, clicks: '2', visitors: '1', __total: '2' }, + ], + }), + } as never) + const result = await service.getBreakdown( + pid, + { + dimension: 'outbound_link', + period: 'all', + limit: 20, + offset: 5, + filters: JSON.stringify([ + { dimension: 'page', operator: 'is', value: '/article' }, + ]), + }, + 'traffic', + ) + const call = jest.mocked(clickhouse.query).mock.calls.at(-1)![0] + expect(call.query).toContain("type = 'outbound'") + expect(call.query).not.toContain("type = 'pageview'") + expect(call.query).toContain('outbound_url AS value') + expect(call.query).toContain('ORDER BY clicks desc, value ASC') + expect(call.query).toContain('count(DISTINCT psid) AS visitors') + expect(call.query).toContain("countIf(type = 'outbound') AS clicks") + expect(call.query).not.toContain('/article') + expect(call.query_params).toMatchObject({ + pid, + v2_limit: 20, + v2_offset: 5, + qf_0_0: '/article', + }) + expect(result.data).toEqual([ + { value: 'https://example.org', clicks: 8, visitors: 3 }, + { value: null, clicks: 2, visitors: 1 }, + ]) + expect(service.resolveTimeframe).toHaveBeenCalledWith( + expect.objectContaining({ allTimeEventTypes: ['outbound'] }), + ) + await expect( + service.getBreakdown( + pid, + { dimension: 'outbound_link', metrics: 'pageviews' }, + 'traffic', + ), + ).rejects.toThrow('clicks and visitors') + }) + + it('keeps normal traffic queries on pageviews', async () => { + const analytics = Object.create(AnalyticsService.prototype) + const service = new AnalyticsV2Service(analytics, {} as never) + expect(service.buildSubQuery('traffic', '', false)).toContain( + "type = 'pageview'", + ) + expect(service.buildSubQuery('traffic', '', true)).toContain( + "type = 'custom_event'", + ) + }) + + it('does not expose the outbound report as a pageview filter', () => { + expect(() => + toV1FiltersJson( + [ + { + dimension: 'outbound_link', + operator: 'is', + value: 'https://example.org', + }, + ], + 'traffic', + ), + ).toThrow() + }) +}) diff --git a/backend/apps/cloud/src/analytics/v2/analytics-v2.service.ts b/backend/apps/cloud/src/analytics/v2/analytics-v2.service.ts index abd452366..bb29fca78 100644 --- a/backend/apps/cloud/src/analytics/v2/analytics-v2.service.ts +++ b/backend/apps/cloud/src/analytics/v2/analytics-v2.service.ts @@ -58,6 +58,7 @@ import { dayjs.extend(utc) type EventsAllTimeType = + | 'outbound' | 'pageview' | 'custom_event' | 'performance' @@ -279,10 +280,20 @@ export class AnalyticsV2Service { measure?: PerfMeasure, ): Promise[]>> { const dimension = getBreakdownDimension(dto.dimension, dataType) - const metrics = - dimension.virtual && !dto.metrics + const isOutbound = dimension.api === 'outbound_link' + const metrics = isOutbound + ? parseMetricsParam(dto.metrics || 'clicks,visitors', dataType) + : dimension.virtual && !dto.metrics ? [getMetric('visitors', dataType)] : parseMetricsParam(dto.metrics, dataType) + if ( + isOutbound && + metrics.some((metric) => !['clicks', 'visitors'].includes(metric.api)) + ) { + throw new UnprocessableEntityException( + 'Outbound links support the clicks and visitors metrics', + ) + } const filters = this.prepareFilters( dto.filters, dataType, @@ -295,16 +306,19 @@ export class AnalyticsV2Service { from: dto.from, to: dto.to, timezone: dto.timezone, + ...(isOutbound ? { allTimeEventTypes: ['outbound'] as const } : {}), }) const limit = dto.limit ?? DEFAULT_BREAKDOWN_LIMIT const offset = dto.offset ?? 0 const sort = parseSortParam(dto.sort, dimension, metrics) - const subQuery = this.buildSubQuery( - dataType, - filters.filtersQuery, - filters.customEVFilterApplied, - ) + const subQuery = isOutbound + ? `FROM events WHERE pid = {pid:FixedString(12)} AND type = 'outbound' ${filters.filtersQuery} AND created BETWEEN {groupFrom:String} AND {groupTo:String}` + : this.buildSubQuery( + dataType, + filters.filtersQuery, + filters.customEVFilterApplied, + ) const meta: Record = { ...this.baseMeta(pid, timeframe, filters.appliedFilters), @@ -342,7 +356,7 @@ export class AnalyticsV2Service { metrics, subQuery, ctx: { - customEVFilterApplied: filters.customEVFilterApplied, + customEVFilterApplied: !isOutbound && filters.customEVFilterApplied, measure, }, sort, @@ -1920,6 +1934,7 @@ export class AnalyticsV2Service { name: dimension.api, description: dimension.description, filterOnly: Boolean(dimension.filterOnly), + breakdownOnly: Boolean(dimension.breakdownOnly), extraFields: (dimension.extraFields ?? []).map((field) => field.api), })) diff --git a/backend/apps/cloud/src/analytics/v2/query/breakdown-query.builder.ts b/backend/apps/cloud/src/analytics/v2/query/breakdown-query.builder.ts index 645f15f9a..320ea71ab 100644 --- a/backend/apps/cloud/src/analytics/v2/query/breakdown-query.builder.ts +++ b/backend/apps/cloud/src/analytics/v2/query/breakdown-query.builder.ts @@ -100,7 +100,7 @@ export const buildBreakdownQuery = (opts: BreakdownQueryOptions): string => { ${subQuery} ${whereParts.join(' ')} GROUP BY ${groupBy.join(', ')} - ORDER BY ${sort.field} ${sort.direction} + ORDER BY ${sort.field} ${sort.direction}${dimension.api === 'outbound_link' && sort.field !== 'value' ? ', value ASC' : ''} LIMIT {v2_limit:UInt32} OFFSET {v2_offset:UInt32} ` } diff --git a/backend/apps/cloud/src/analytics/v2/query/filters.translator.ts b/backend/apps/cloud/src/analytics/v2/query/filters.translator.ts index 833cb0900..c36039abb 100644 --- a/backend/apps/cloud/src/analytics/v2/query/filters.translator.ts +++ b/backend/apps/cloud/src/analytics/v2/query/filters.translator.ts @@ -66,7 +66,7 @@ const resolveColumn = ( const def = findDimension(dimension, type) - if (!def) { + if (!def || def.breakdownOnly) { if (lenient) { return null } diff --git a/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts b/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts index 75bd85cab..52763f1cd 100644 --- a/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts +++ b/backend/apps/cloud/src/analytics/v2/registry/dimensions.ts @@ -9,6 +9,13 @@ import { V2DimensionDef } from './types' const GEO_TYPES: V2DimensionDef['types'] = ['traffic', 'performance', 'errors'] export const V2_DIMENSIONS: V2DimensionDef[] = [ + { + api: 'outbound_link', + column: 'outbound_url', + types: ['traffic'], + breakdownOnly: true, + description: 'Outbound destination URL (clicks and visitors)', + }, { api: 'country', column: 'cc', diff --git a/backend/apps/cloud/src/analytics/v2/registry/metrics.ts b/backend/apps/cloud/src/analytics/v2/registry/metrics.ts index 4f5d81254..74f41481c 100644 --- a/backend/apps/cloud/src/analytics/v2/registry/metrics.ts +++ b/backend/apps/cloud/src/analytics/v2/registry/metrics.ts @@ -17,6 +17,13 @@ const perfExpr = } export const V2_METRICS: V2MetricDef[] = [ + { + api: 'clicks', + types: ['traffic'], + sqlExpr: () => "countIf(type = 'outbound')", + format: 'integer', + description: 'Outbound link clicks', + }, { api: 'visitors', types: ['traffic'], diff --git a/backend/apps/cloud/src/analytics/v2/registry/types.ts b/backend/apps/cloud/src/analytics/v2/registry/types.ts index 49ee973b5..916c20dee 100644 --- a/backend/apps/cloud/src/analytics/v2/registry/types.ts +++ b/backend/apps/cloud/src/analytics/v2/registry/types.ts @@ -14,6 +14,7 @@ export interface V2DimensionDef { extraFields?: V2DimensionExtraField[] excludeNull?: boolean filterOnly?: boolean + breakdownOnly?: boolean virtual?: 'entry_page' | 'exit_page' extraWhere?: string description: string diff --git a/backend/apps/cloud/src/data-import/data-import.service.ts b/backend/apps/cloud/src/data-import/data-import.service.ts index 76683f678..ec8436fd6 100644 --- a/backend/apps/cloud/src/data-import/data-import.service.ts +++ b/backend/apps/cloud/src/data-import/data-import.service.ts @@ -161,7 +161,7 @@ export class DataImportService { importId: number, ): Promise { await clickhouse.command({ - query: `ALTER TABLE ${CLICKHOUSE_DB}.events DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8} AND type IN ('pageview', 'custom_event')`, + query: `ALTER TABLE ${CLICKHOUSE_DB}.events DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8} AND type IN ('pageview', 'custom_event', 'outbound')`, query_params: { pid: projectId, importID: importId }, clickhouse_settings: { mutations_sync: '2' }, }) diff --git a/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts b/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts index cf19c69c8..2bf688bee 100644 --- a/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts +++ b/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts @@ -6,7 +6,7 @@ export class ImportError extends Error { } export interface AnalyticsImportRow { - type: 'pageview' | 'custom_event' + type: 'pageview' | 'custom_event' | 'outbound' data: Record } diff --git a/backend/apps/cloud/src/data-import/mappers/plausible.mapper.ts b/backend/apps/cloud/src/data-import/mappers/plausible.mapper.ts index 78fda941d..ba92b4251 100644 --- a/backend/apps/cloud/src/data-import/mappers/plausible.mapper.ts +++ b/backend/apps/cloud/src/data-import/mappers/plausible.mapper.ts @@ -122,6 +122,7 @@ interface ExitPageRow { interface CustomEventRow { name: string + url: string | null events: number } @@ -764,7 +765,9 @@ export class PlausibleMapper implements ImportMapper { const name = normalizeNull(row.name) const events = toInt(row.events, 'events') if (!name || events <= 0) continue - days.get(date)!.customEvents.push({ name, events }) + days + .get(date)! + .customEvents.push({ name, events, url: normalizeNull(row.link_url) }) } } @@ -1010,8 +1013,13 @@ export class PlausibleMapper implements ImportMapper { page: null, created, }) - data.event_name = truncate(ev.name, 256) || '' - yield { type: 'custom_event', data } + if (ev.name === 'Outbound Link: Click') { + data.outbound_url = truncate(ev.url, 2048) + yield { type: 'outbound', data } + } else { + data.event_name = truncate(ev.name, 256) || '' + yield { type: 'custom_event', data } + } } } } diff --git a/backend/apps/cloud/src/project/plan-usage.spec.ts b/backend/apps/cloud/src/project/plan-usage.spec.ts index 2051fde57..bfa569344 100644 --- a/backend/apps/cloud/src/project/plan-usage.spec.ts +++ b/backend/apps/cloud/src/project/plan-usage.spec.ts @@ -1,5 +1,9 @@ jest.mock('../common/constants', () => ({ - redis: { del: jest.fn() }, + redis: { + del: jest.fn(), + get: jest.fn().mockResolvedValue(null), + set: jest.fn(), + }, getRedisUserCountKey: (uid: string) => `count:${uid}`, getRedisUserUsageInfoKey: (uid: string) => `usage:${uid}`, })) @@ -86,10 +90,43 @@ describe('usage after traffic cleanup', () => { nextMonthStart: '2026-10-01 00:00:00', }) expect(query.query).toContain('importID IS NULL') - expect(query.query).toContain("'pageview', 'custom_event', 'error'") + expect(query.query).toContain( + "'pageview', 'custom_event', 'error', 'outbound'", + ) expect(query.query).toContain("= 'pass'") }) + it('counts native outbound clicks in cached usage without treating them as custom events', async () => { + service.find = jest.fn().mockResolvedValue([{ id: 'project00001' }]) + jest.mocked(clickhouse.query).mockResolvedValue({ + json: async () => ({ + data: [ + { + traffic: 10, + pageviews: 10, + customEvents: 3, + outboundLinks: 7, + captcha: 2, + errors: 1, + }, + ], + }), + } as never) + expect(await service.getRedisCount('owner')).toBe(23) + expect(await service.getRedisUsageInfo('owner')).toMatchObject({ + total: 23, + traffic: 10, + customEvents: 3, + outboundLinks: 7, + }) + for (const [query] of jest.mocked(clickhouse.query).mock.calls) { + expect(query.query).toContain('importID IS NULL') + expect(query.query).toContain( + "countIf(type = 'outbound') AS outboundLinks", + ) + } + }) + it('does not resolve usage restrictions when the usage query fails', async () => { jest .mocked(clickhouse.query) diff --git a/backend/apps/cloud/src/project/project.service.ts b/backend/apps/cloud/src/project/project.service.ts index ca13ca930..509160dfa 100644 --- a/backend/apps/cloud/src/project/project.service.ts +++ b/backend/apps/cloud/src/project/project.service.ts @@ -767,7 +767,7 @@ export class ProjectService { to: string, ): Promise { const queries = [ - "ALTER TABLE events DELETE WHERE pid = {pid:FixedString(12)} AND type IN ('pageview', 'custom_event', 'performance', 'error', 'captcha') AND created BETWEEN {from:String} AND {to:String}", + "ALTER TABLE events DELETE WHERE pid = {pid:FixedString(12)} AND type IN ('pageview', 'custom_event', 'performance', 'error', 'captcha', 'outbound') AND created BETWEEN {from:String} AND {to:String}", 'ALTER TABLE error_statuses DELETE WHERE pid = {pid:FixedString(12)} AND created BETWEEN {from:String} AND {to:String}', ] @@ -898,7 +898,7 @@ export class ProjectService { countIf(created < {monthStart:DateTime}) AS previous FROM events WHERE pid IN ({pids:Array(FixedString(12))}) - AND (type IN ('pageview', 'custom_event', 'error') OR (${CAPTCHA_PASS_CONDITION})) + AND (type IN ('pageview', 'custom_event', 'error', 'outbound') OR (${CAPTCHA_PASS_CONDITION})) AND importID IS NULL AND created >= {previousMonthStart:DateTime} AND created < {nextMonthStart:DateTime} @@ -1006,6 +1006,7 @@ export class ProjectService { const pids = _map(projects, 'id') let totalPageviews = 0 let totalCustomEvents = 0 + let totalOutboundLinks = 0 let totalCaptcha = 0 let totalErrors = 0 @@ -1022,12 +1023,13 @@ export class ProjectService { SELECT countIf(type = 'pageview') AS pageviews, countIf(type = 'custom_event') AS customEvents, + countIf(type = 'outbound') AS outboundLinks, countIf(${CAPTCHA_PASS_CONDITION}) AS captcha, countIf(type = 'error') AS errors FROM events WHERE created BETWEEN {monthStart:String} AND {monthEnd:String} AND pid IN ({pids:Array(FixedString(12))}) - AND type IN ('pageview', 'custom_event', 'captcha', 'error') + AND type IN ('pageview', 'custom_event', 'captcha', 'error', 'outbound') AND importID IS NULL ` @@ -1040,6 +1042,7 @@ export class ProjectService { resultSet.json<{ pageviews: string | number customEvents: string | number + outboundLinks: string | number captcha: string | number errors: string | number }>(), @@ -1048,11 +1051,17 @@ export class ProjectService { const row = counts[0] totalPageviews += Number(row?.pageviews) || 0 totalCustomEvents += Number(row?.customEvents) || 0 + totalOutboundLinks += Number(row?.outboundLinks) || 0 totalCaptcha += Number(row?.captcha) || 0 totalErrors += Number(row?.errors) || 0 } - count = totalPageviews + totalCustomEvents + totalCaptcha + totalErrors + count = + totalPageviews + + totalCustomEvents + + totalOutboundLinks + + totalCaptcha + + totalErrors await redis.set(countKey, `${count}`, 'EX', redisProjectCountCacheTimeout) } else { @@ -1104,6 +1113,7 @@ export class ProjectService { const pids = _map(projects, 'id') let totalTraffic = 0 let totalCustomEvents = 0 + let totalOutboundLinks = 0 let totalCaptcha = 0 let totalErrors = 0 @@ -1120,12 +1130,13 @@ export class ProjectService { SELECT countIf(type = 'pageview') AS traffic, countIf(type = 'custom_event') AS customEvents, + countIf(type = 'outbound') AS outboundLinks, countIf(${CAPTCHA_PASS_CONDITION}) AS captcha, countIf(type = 'error') AS errors FROM events WHERE pid IN ({pids:Array(FixedString(12))}) AND created BETWEEN {periodStart:String} AND {periodEnd:String} - AND type IN ('pageview', 'custom_event', 'captcha', 'error') + AND type IN ('pageview', 'custom_event', 'captcha', 'error', 'outbound') AND importID IS NULL ` @@ -1138,6 +1149,7 @@ export class ProjectService { resultSet.json<{ traffic: string | number customEvents: string | number + outboundLinks: string | number captcha: string | number errors: string | number }>(), @@ -1146,17 +1158,23 @@ export class ProjectService { const row = counts[0] totalTraffic += Number(row?.traffic) || 0 totalCustomEvents += Number(row?.customEvents) || 0 + totalOutboundLinks += Number(row?.outboundLinks) || 0 totalCaptcha += Number(row?.captcha) || 0 totalErrors += Number(row?.errors) || 0 } const total = - totalTraffic + totalCustomEvents + totalCaptcha + totalErrors + totalTraffic + + totalCustomEvents + + totalOutboundLinks + + totalCaptcha + + totalErrors info = { total, traffic: totalTraffic, customEvents: totalCustomEvents, + outboundLinks: totalOutboundLinks, captcha: totalCaptcha, errors: totalErrors, } @@ -1281,7 +1299,7 @@ export class ProjectService { SELECT DISTINCT pid FROM events WHERE pid IN (${pids}) - AND type IN ('pageview', 'custom_event') + AND type IN ('pageview', 'custom_event', 'outbound') ` const { data } = await clickhouse diff --git a/backend/apps/cloud/src/task-manager/task-manager.service.ts b/backend/apps/cloud/src/task-manager/task-manager.service.ts index 1f83d2f01..85f6cb610 100644 --- a/backend/apps/cloud/src/task-manager/task-manager.service.ts +++ b/backend/apps/cloud/src/task-manager/task-manager.service.ts @@ -547,10 +547,10 @@ export class TaskManagerService { for (let i = 0; i < pids.length; i += CHUNK_SIZE) { const pidChunk = pids.slice(i, i + CHUNK_SIZE) const query = ` - SELECT countIf(type IN ('pageview', 'custom_event', 'error', 'performance') OR (${CAPTCHA_PASS_CONDITION})) AS totalEvents + SELECT countIf(type IN ('pageview', 'custom_event', 'error', 'performance', 'outbound') OR (${CAPTCHA_PASS_CONDITION})) AS totalEvents FROM events WHERE pid IN ({pids:Array(FixedString(12))}) - AND type IN ('pageview', 'custom_event', 'error', 'captcha', 'performance') + AND type IN ('pageview', 'custom_event', 'error', 'captcha', 'performance', 'outbound') ` const { data } = await clickhouse @@ -2000,7 +2000,7 @@ export class TaskManagerService { return } - const queryEvents = `SELECT 1 FROM events WHERE pid IN ({pids:Array(FixedString(12))}) AND type IN ('pageview', 'captcha', 'custom_event', 'error', 'performance') AND created BETWEEN {nineWeeksAgo:String} AND {now:String} LIMIT 1` + const queryEvents = `SELECT 1 FROM events WHERE pid IN ({pids:Array(FixedString(12))}) AND type IN ('pageview', 'captcha', 'custom_event', 'error', 'performance', 'outbound') AND created BETWEEN {nineWeeksAgo:String} AND {now:String} LIMIT 1` // Process project IDs in chunks to avoid ClickHouse field value limit let totalEvents = 0 diff --git a/backend/apps/cloud/src/user/interfaces/usage-info.ts b/backend/apps/cloud/src/user/interfaces/usage-info.ts index 080fe26d4..119e92248 100644 --- a/backend/apps/cloud/src/user/interfaces/usage-info.ts +++ b/backend/apps/cloud/src/user/interfaces/usage-info.ts @@ -2,6 +2,7 @@ export interface IUsageInfoRedis { total: number traffic: number customEvents: number + outboundLinks?: number captcha: number errors: number } @@ -9,6 +10,7 @@ export interface IUsageInfoRedis { export interface IUsageInfoBreakdown extends IUsageInfoRedis { trafficPerc: number customEventsPerc: number + outboundLinksPerc: number captchaPerc: number errorsPerc: number } diff --git a/backend/apps/cloud/src/user/user.controller.ts b/backend/apps/cloud/src/user/user.controller.ts index e17531e68..8bd5dcea0 100644 --- a/backend/apps/cloud/src/user/user.controller.ts +++ b/backend/apps/cloud/src/user/user.controller.ts @@ -99,6 +99,10 @@ const formatUsageInfo = ( return { ...usage, + outboundLinks: usage.outboundLinks || 0, + outboundLinksPerc: usage.total + ? _round(((usage.outboundLinks || 0) / usage.total) * 100, 2) + : 0, trafficPerc: usage.total ? _round((usage.traffic / usage.total) * 100, 2) : 0, diff --git a/backend/apps/community/src/analytics/analytics.controller.ts b/backend/apps/community/src/analytics/analytics.controller.ts index 00145a730..e793b9307 100644 --- a/backend/apps/community/src/analytics/analytics.controller.ts +++ b/backend/apps/community/src/analytics/analytics.controller.ts @@ -87,6 +87,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 { MAX_METRICS_IN_VIEW } from '../project/dto/create-project-view.dto' @@ -1324,6 +1325,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 || '' @@ -1344,7 +1374,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, @@ -1372,7 +1402,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', ) @@ -1404,13 +1434,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, @@ -1443,16 +1474,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 (e) { this.logger.error(e) throw new InternalServerErrorException( - 'Error occured while saving the custom event', + 'Error occurred while saving the event', ) } diff --git a/backend/apps/community/src/analytics/analytics.service.ts b/backend/apps/community/src/analytics/analytics.service.ts index 43240aa18..634708627 100644 --- a/backend/apps/community/src/analytics/analytics.service.ts +++ b/backend/apps/community/src/analytics/analytics.service.ts @@ -677,6 +677,7 @@ export enum DataType { type AnalyticsEventType = 'pageview' | 'custom_event' type EventsAllTimeType = + | 'outbound' | AnalyticsEventType | 'performance' | 'error' diff --git a/backend/apps/community/src/analytics/dto/data-deletion.dto.ts b/backend/apps/community/src/analytics/dto/data-deletion.dto.ts index 2ae16ca5f..878baac3a 100644 --- a/backend/apps/community/src/analytics/dto/data-deletion.dto.ts +++ b/backend/apps/community/src/analytics/dto/data-deletion.dto.ts @@ -14,6 +14,7 @@ import { PID_REGEX } from '../../common/constants' export const DATA_DELETION_EVENT_TYPES = [ 'pageview', 'custom_event', + 'outbound', 'error', 'performance', 'captcha', diff --git a/backend/apps/community/src/analytics/dto/outbound.dto.ts b/backend/apps/community/src/analytics/dto/outbound.dto.ts new file mode 100644 index 000000000..977c831c6 --- /dev/null +++ b/backend/apps/community/src/analytics/dto/outbound.dto.ts @@ -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 +} diff --git a/backend/apps/community/src/analytics/utils/transformers.ts b/backend/apps/community/src/analytics/utils/transformers.ts index bd1ef1e99..43686fb97 100644 --- a/backend/apps/community/src/analytics/utils/transformers.ts +++ b/backend/apps/community/src/analytics/utils/transformers.ts @@ -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 @@ -103,6 +109,7 @@ interface CaptchaOptions { type EventTransformerOptions = | PageviewOptions | CustomEventOptions + | OutboundEventOptions | ErrorEventOptions | PerformanceOptions | CaptchaOptions @@ -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, diff --git a/backend/apps/community/src/analytics/v2/__tests__/outbound-ingestion.spec.ts b/backend/apps/community/src/analytics/v2/__tests__/outbound-ingestion.spec.ts new file mode 100644 index 000000000..9313383ad --- /dev/null +++ b/backend/apps/community/src/analytics/v2/__tests__/outbound-ingestion.spec.ts @@ -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 + 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() + }) +}) diff --git a/backend/apps/community/src/analytics/v2/__tests__/outbound-links.spec.ts b/backend/apps/community/src/analytics/v2/__tests__/outbound-links.spec.ts new file mode 100644 index 000000000..a871ea124 --- /dev/null +++ b/backend/apps/community/src/analytics/v2/__tests__/outbound-links.spec.ts @@ -0,0 +1,146 @@ +import { plainToInstance } from 'class-transformer' +import { validate } from 'class-validator' +import { AnalyticsService } from '../../analytics.service' +import { AnalyticsV2Service } from '../analytics-v2.service' +import { OutboundDto } from '../../dto/outbound.dto' +import { eventTransformer } from '../../utils/transformers' +import { toV1FiltersJson } from '../query/filters.translator' +import { clickhouse } from '../../../common/integrations/clickhouse' + +jest.mock('../../../common/integrations/clickhouse', () => ({ + clickhouse: { query: jest.fn() }, +})) + +const pid = 'testproject1' + +describe('Outbound links', () => { + it('validates and stores a native outbound event with its source context', async () => { + const dto = plainToInstance(OutboundDto, { + pid, + url: 'https://tickets.example.org/event?id=4', + pg: '/article', + }) + expect(await validate(dto)).toEqual([]) + expect( + eventTransformer({ type: 'outbound', ...dto, psid: '123' }), + ).toMatchObject({ + type: 'outbound', + outbound_url: dto.url, + pg: '/article', + psid: '123', + }) + expect(eventTransformer({ type: 'outbound', ...dto })).not.toHaveProperty( + 'event_name', + ) + }) + + it.each([ + undefined, + null, + 123, + '', + '/relative', + 'javascript:alert(1)', + 'ftp://example.org', + 'https://user:secret@example.org', + `https://example.org/${'a'.repeat(2048)}`, + ])('rejects invalid destinations (%#)', async (url) => { + const errors = await validate(plainToInstance(OutboundDto, { pid, url })) + expect(errors.some((error) => error.property === 'url')).toBe(true) + }) + + it('groups clicks and unique visitors with bound filters and pagination', async () => { + const analytics = Object.create(AnalyticsService.prototype) + const service = new AnalyticsV2Service(analytics, {} as never) + jest.spyOn(service, 'resolveTimeframe').mockResolvedValue({ + period: 'all', + timeBucket: null, + allowedTimeBuckets: null, + safeTimezone: 'UTC', + groupFrom: '2026-01-01 00:00:00', + groupTo: '2026-10-13 23:59:59', + groupFromUTC: '2026-01-01 00:00:00', + groupToUTC: '2026-10-13 23:59:59', + }) + jest.mocked(clickhouse.query).mockResolvedValue({ + json: async () => ({ + data: [ + { + value: 'https://example.org', + clicks: '8', + visitors: '3', + __total: '2', + }, + { value: null, clicks: '2', visitors: '1', __total: '2' }, + ], + }), + } as never) + const result = await service.getBreakdown( + pid, + { + dimension: 'outbound_link', + period: 'all', + limit: 20, + offset: 5, + filters: JSON.stringify([ + { dimension: 'page', operator: 'is', value: '/article' }, + ]), + }, + 'traffic', + ) + const call = jest.mocked(clickhouse.query).mock.calls.at(-1)![0] + expect(call.query).toContain("type = 'outbound'") + expect(call.query).not.toContain("type = 'pageview'") + expect(call.query).toContain('outbound_url AS value') + expect(call.query).toContain('ORDER BY clicks desc, value ASC') + expect(call.query).toContain('count(DISTINCT psid) AS visitors') + expect(call.query).toContain("countIf(type = 'outbound') AS clicks") + expect(call.query).not.toContain('/article') + expect(call.query_params).toMatchObject({ + pid, + v2_limit: 20, + v2_offset: 5, + qf_0_0: '/article', + }) + expect(result.data).toEqual([ + { value: 'https://example.org', clicks: 8, visitors: 3 }, + { value: null, clicks: 2, visitors: 1 }, + ]) + expect(service.resolveTimeframe).toHaveBeenCalledWith( + expect.objectContaining({ allTimeEventTypes: ['outbound'] }), + ) + await expect( + service.getBreakdown( + pid, + { dimension: 'outbound_link', metrics: 'pageviews' }, + 'traffic', + ), + ).rejects.toThrow('clicks and visitors') + }) + + it('keeps normal traffic queries on pageviews', async () => { + const analytics = Object.create(AnalyticsService.prototype) + const service = new AnalyticsV2Service(analytics, {} as never) + expect(service.buildSubQuery('traffic', '', false)).toContain( + "type = 'pageview'", + ) + expect(service.buildSubQuery('traffic', '', true)).toContain( + "type = 'custom_event'", + ) + }) + + it('does not expose the outbound report as a pageview filter', () => { + expect(() => + toV1FiltersJson( + [ + { + dimension: 'outbound_link', + operator: 'is', + value: 'https://example.org', + }, + ], + 'traffic', + ), + ).toThrow() + }) +}) diff --git a/backend/apps/community/src/analytics/v2/__tests__/registry.spec.ts b/backend/apps/community/src/analytics/v2/__tests__/registry.spec.ts index 34ae77bfc..007e1e1f4 100644 --- a/backend/apps/community/src/analytics/v2/__tests__/registry.spec.ts +++ b/backend/apps/community/src/analytics/v2/__tests__/registry.spec.ts @@ -52,8 +52,10 @@ describe('registry <-> v1 column allowlist lockstep', () => { describe('V2_VIEW_FILTER_DIMENSIONS <-> registry parity', () => { const expected = [ - ...V2_DIMENSIONS.filter((dimension) => - dimension.types.some((type) => type !== 'captcha'), + ...V2_DIMENSIONS.filter( + (dimension) => + !dimension.breakdownOnly && + dimension.types.some((type) => type !== 'captcha'), ).map((dimension) => dimension.api), ...Object.keys(V2_KEYED_FILTER_DIMENSIONS), ] diff --git a/backend/apps/community/src/analytics/v2/analytics-v2.service.ts b/backend/apps/community/src/analytics/v2/analytics-v2.service.ts index 8f1670bbf..5c7515311 100644 --- a/backend/apps/community/src/analytics/v2/analytics-v2.service.ts +++ b/backend/apps/community/src/analytics/v2/analytics-v2.service.ts @@ -58,6 +58,7 @@ import { dayjs.extend(utc) type EventsAllTimeType = + | 'outbound' | 'pageview' | 'custom_event' | 'performance' @@ -278,10 +279,20 @@ export class AnalyticsV2Service { measure?: PerfMeasure, ): Promise[]>> { const dimension = getBreakdownDimension(dto.dimension, dataType) - const metrics = - dimension.virtual && !dto.metrics + const isOutbound = dimension.api === 'outbound_link' + const metrics = isOutbound + ? parseMetricsParam(dto.metrics || 'clicks,visitors', dataType) + : dimension.virtual && !dto.metrics ? [getMetric('visitors', dataType)] : parseMetricsParam(dto.metrics, dataType) + if ( + isOutbound && + metrics.some((metric) => !['clicks', 'visitors'].includes(metric.api)) + ) { + throw new UnprocessableEntityException( + 'Outbound links support the clicks and visitors metrics', + ) + } const filters = this.prepareFilters( dto.filters, dataType, @@ -294,16 +305,19 @@ export class AnalyticsV2Service { from: dto.from, to: dto.to, timezone: dto.timezone, + ...(isOutbound ? { allTimeEventTypes: ['outbound'] as const } : {}), }) const limit = dto.limit ?? DEFAULT_BREAKDOWN_LIMIT const offset = dto.offset ?? 0 const sort = parseSortParam(dto.sort, dimension, metrics) - const subQuery = this.buildSubQuery( - dataType, - filters.filtersQuery, - filters.customEVFilterApplied, - ) + const subQuery = isOutbound + ? `FROM events WHERE pid = {pid:FixedString(12)} AND type = 'outbound' ${filters.filtersQuery} AND created BETWEEN {groupFrom:String} AND {groupTo:String}` + : this.buildSubQuery( + dataType, + filters.filtersQuery, + filters.customEVFilterApplied, + ) const meta: Record = { ...this.baseMeta(pid, timeframe, filters.appliedFilters), @@ -341,7 +355,7 @@ export class AnalyticsV2Service { metrics, subQuery, ctx: { - customEVFilterApplied: filters.customEVFilterApplied, + customEVFilterApplied: !isOutbound && filters.customEVFilterApplied, measure, }, sort, @@ -1919,6 +1933,7 @@ export class AnalyticsV2Service { name: dimension.api, description: dimension.description, filterOnly: Boolean(dimension.filterOnly), + breakdownOnly: Boolean(dimension.breakdownOnly), extraFields: (dimension.extraFields ?? []).map((field) => field.api), })) diff --git a/backend/apps/community/src/analytics/v2/query/breakdown-query.builder.ts b/backend/apps/community/src/analytics/v2/query/breakdown-query.builder.ts index 645f15f9a..320ea71ab 100644 --- a/backend/apps/community/src/analytics/v2/query/breakdown-query.builder.ts +++ b/backend/apps/community/src/analytics/v2/query/breakdown-query.builder.ts @@ -100,7 +100,7 @@ export const buildBreakdownQuery = (opts: BreakdownQueryOptions): string => { ${subQuery} ${whereParts.join(' ')} GROUP BY ${groupBy.join(', ')} - ORDER BY ${sort.field} ${sort.direction} + ORDER BY ${sort.field} ${sort.direction}${dimension.api === 'outbound_link' && sort.field !== 'value' ? ', value ASC' : ''} LIMIT {v2_limit:UInt32} OFFSET {v2_offset:UInt32} ` } diff --git a/backend/apps/community/src/analytics/v2/query/filters.translator.ts b/backend/apps/community/src/analytics/v2/query/filters.translator.ts index 833cb0900..c36039abb 100644 --- a/backend/apps/community/src/analytics/v2/query/filters.translator.ts +++ b/backend/apps/community/src/analytics/v2/query/filters.translator.ts @@ -66,7 +66,7 @@ const resolveColumn = ( const def = findDimension(dimension, type) - if (!def) { + if (!def || def.breakdownOnly) { if (lenient) { return null } diff --git a/backend/apps/community/src/analytics/v2/registry/dimensions.ts b/backend/apps/community/src/analytics/v2/registry/dimensions.ts index 75bd85cab..52763f1cd 100644 --- a/backend/apps/community/src/analytics/v2/registry/dimensions.ts +++ b/backend/apps/community/src/analytics/v2/registry/dimensions.ts @@ -9,6 +9,13 @@ import { V2DimensionDef } from './types' const GEO_TYPES: V2DimensionDef['types'] = ['traffic', 'performance', 'errors'] export const V2_DIMENSIONS: V2DimensionDef[] = [ + { + api: 'outbound_link', + column: 'outbound_url', + types: ['traffic'], + breakdownOnly: true, + description: 'Outbound destination URL (clicks and visitors)', + }, { api: 'country', column: 'cc', diff --git a/backend/apps/community/src/analytics/v2/registry/metrics.ts b/backend/apps/community/src/analytics/v2/registry/metrics.ts index 4f5d81254..74f41481c 100644 --- a/backend/apps/community/src/analytics/v2/registry/metrics.ts +++ b/backend/apps/community/src/analytics/v2/registry/metrics.ts @@ -17,6 +17,13 @@ const perfExpr = } export const V2_METRICS: V2MetricDef[] = [ + { + api: 'clicks', + types: ['traffic'], + sqlExpr: () => "countIf(type = 'outbound')", + format: 'integer', + description: 'Outbound link clicks', + }, { api: 'visitors', types: ['traffic'], diff --git a/backend/apps/community/src/analytics/v2/registry/types.ts b/backend/apps/community/src/analytics/v2/registry/types.ts index 49ee973b5..916c20dee 100644 --- a/backend/apps/community/src/analytics/v2/registry/types.ts +++ b/backend/apps/community/src/analytics/v2/registry/types.ts @@ -14,6 +14,7 @@ export interface V2DimensionDef { extraFields?: V2DimensionExtraField[] excludeNull?: boolean filterOnly?: boolean + breakdownOnly?: boolean virtual?: 'entry_page' | 'exit_page' extraWhere?: string description: string diff --git a/backend/apps/community/src/data-import/data-import.service.ts b/backend/apps/community/src/data-import/data-import.service.ts index 349fb7db9..b2b167556 100644 --- a/backend/apps/community/src/data-import/data-import.service.ts +++ b/backend/apps/community/src/data-import/data-import.service.ts @@ -307,7 +307,7 @@ export class DataImportService { importId: number, ): Promise { await clickhouse.command({ - query: `ALTER TABLE ${CLICKHOUSE_DB}.events DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8} AND type IN ('pageview', 'custom_event')`, + query: `ALTER TABLE ${CLICKHOUSE_DB}.events DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8} AND type IN ('pageview', 'custom_event', 'outbound')`, query_params: { pid: projectId, importID: importId }, clickhouse_settings: { mutations_sync: '2' }, }) diff --git a/backend/apps/community/src/data-import/mappers/mapper.interface.ts b/backend/apps/community/src/data-import/mappers/mapper.interface.ts index cf19c69c8..2bf688bee 100644 --- a/backend/apps/community/src/data-import/mappers/mapper.interface.ts +++ b/backend/apps/community/src/data-import/mappers/mapper.interface.ts @@ -6,7 +6,7 @@ export class ImportError extends Error { } export interface AnalyticsImportRow { - type: 'pageview' | 'custom_event' + type: 'pageview' | 'custom_event' | 'outbound' data: Record } diff --git a/backend/apps/community/src/data-import/mappers/plausible.mapper.ts b/backend/apps/community/src/data-import/mappers/plausible.mapper.ts index 78fda941d..ba92b4251 100644 --- a/backend/apps/community/src/data-import/mappers/plausible.mapper.ts +++ b/backend/apps/community/src/data-import/mappers/plausible.mapper.ts @@ -122,6 +122,7 @@ interface ExitPageRow { interface CustomEventRow { name: string + url: string | null events: number } @@ -764,7 +765,9 @@ export class PlausibleMapper implements ImportMapper { const name = normalizeNull(row.name) const events = toInt(row.events, 'events') if (!name || events <= 0) continue - days.get(date)!.customEvents.push({ name, events }) + days + .get(date)! + .customEvents.push({ name, events, url: normalizeNull(row.link_url) }) } } @@ -1010,8 +1013,13 @@ export class PlausibleMapper implements ImportMapper { page: null, created, }) - data.event_name = truncate(ev.name, 256) || '' - yield { type: 'custom_event', data } + if (ev.name === 'Outbound Link: Click') { + data.outbound_url = truncate(ev.url, 2048) + yield { type: 'outbound', data } + } else { + data.event_name = truncate(ev.name, 256) || '' + yield { type: 'custom_event', data } + } } } } diff --git a/backend/apps/community/src/project/project.service.ts b/backend/apps/community/src/project/project.service.ts index eaa9bf037..067bb6b4a 100644 --- a/backend/apps/community/src/project/project.service.ts +++ b/backend/apps/community/src/project/project.service.ts @@ -251,7 +251,7 @@ export class ProjectService { SELECT DISTINCT pid FROM events WHERE pid IN (${pids}) - AND type IN ('pageview', 'custom_event') + AND type IN ('pageview', 'custom_event', 'outbound') ` const { data } = await clickhouse @@ -342,7 +342,7 @@ export class ProjectService { to: string, ): Promise { const queries = [ - "ALTER TABLE events DELETE WHERE pid = {pid:FixedString(12)} AND type IN ('pageview', 'custom_event', 'performance', 'error', 'captcha') AND created BETWEEN {from:String} AND {to:String}", + "ALTER TABLE events DELETE WHERE pid = {pid:FixedString(12)} AND type IN ('pageview', 'custom_event', 'performance', 'error', 'captcha', 'outbound') AND created BETWEEN {from:String} AND {to:String}", 'ALTER TABLE error_statuses DELETE WHERE pid = {pid:FixedString(12)} AND created BETWEEN {from:String} AND {to:String}', ] diff --git a/backend/migrations/clickhouse/2026_10_13_outbound_links.js b/backend/migrations/clickhouse/2026_10_13_outbound_links.js new file mode 100644 index 000000000..174d059a3 --- /dev/null +++ b/backend/migrations/clickhouse/2026_10_13_outbound_links.js @@ -0,0 +1,5 @@ +const { queriesRunner, dbName } = require('./setup') + +queriesRunner([ + `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS outbound_url Nullable(String) CODEC(ZSTD(3)) AFTER event_name`, +]) diff --git a/backend/migrations/clickhouse/initialise_database.js b/backend/migrations/clickhouse/initialise_database.js index a13a0aa16..6637d63e0 100644 --- a/backend/migrations/clickhouse/initialise_database.js +++ b/backend/migrations/clickhouse/initialise_database.js @@ -61,6 +61,8 @@ const CLICKHOUSE_INIT_QUERIES = [ `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS title Nullable(String) CODEC(ZSTD(3)) AFTER pg`, + `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS outbound_url Nullable(String) CODEC(ZSTD(3)) AFTER event_name`, + // Error events status table `CREATE TABLE IF NOT EXISTS ${dbName}.error_statuses ( eid FixedString(32), diff --git a/backend/migrations/clickhouse/selfhosted_2026_10_13_outbound_links.js b/backend/migrations/clickhouse/selfhosted_2026_10_13_outbound_links.js new file mode 100644 index 000000000..174d059a3 --- /dev/null +++ b/backend/migrations/clickhouse/selfhosted_2026_10_13_outbound_links.js @@ -0,0 +1,5 @@ +const { queriesRunner, dbName } = require('./setup') + +queriesRunner([ + `ALTER TABLE ${dbName}.events ADD COLUMN IF NOT EXISTS outbound_url Nullable(String) CODEC(ZSTD(3)) AFTER event_name`, +]) diff --git a/backend/tests/data-import-mappers.spec.ts b/backend/tests/data-import-mappers.spec.ts index 1fd7a841b..27ba40dac 100644 --- a/backend/tests/data-import-mappers.spec.ts +++ b/backend/tests/data-import-mappers.spec.ts @@ -75,6 +75,31 @@ describe.each([ expect(rows.every((row) => row.data.importID === 7)).toBe(true) }) + it('imports Plausible outbound destinations separately without losing counts', async () => { + const file = archive({ + 'imported_visitors.csv': visitors, + 'imported_custom_events.csv': + 'date,name,link_url,events\n2024-01-01,Outbound Link: Click,https://tickets.example.com/event,3\n2024-01-01,Outbound Link: Click,,2\n2024-01-01,Signup,,1\n', + }) + const rows = await collect( + new Plausible().createRowStream(file, 'testproject1', 7), + ) + const outbound = rows.filter((row) => row.type === 'outbound') + expect(outbound).toHaveLength(5) + expect( + outbound.filter( + (row) => row.data.outbound_url === 'https://tickets.example.com/event', + ), + ).toHaveLength(3) + expect( + outbound.filter((row) => row.data.outbound_url === null), + ).toHaveLength(2) + expect( + outbound.every((row) => row.data.importID === 7 && !row.data.event_name), + ).toBe(true) + expect(rows.filter((row) => row.type === 'custom_event')).toHaveLength(1) + }) + it('handles a 57 MiB ZIP containing a CSV above the former 50 MiB limit', async () => { const padding = 'x'.repeat(2000) const record = `2024-01-01,example.com,/large,1,${padding}\n` diff --git a/docs/content/docs/api/events.mdx b/docs/content/docs/api/events.mdx index 43d27d913..0a997469d 100644 --- a/docs/content/docs/api/events.mdx +++ b/docs/content/docs/api/events.mdx @@ -191,6 +191,19 @@ curl -i -X POST https://api.swetrix.com/log/custom \ {} ``` +### POST /log/outbound + +Records a native outbound link click. Use `events: { outboundLinks: true }` in the browser tracker's `init()` options for automatic capture. + +```bash title="Request" +curl -i -X POST https://api.swetrix.com/log/outbound \ + -H "Content-Type: application/json" \ + -H "Origin: https://example.com" \ + -d '{"pid":"YOUR_PROJECT_ID","url":"https://tickets.example.org/event","pg":"/article"}' +``` + +`url` is required: an absolute HTTP(S) destination, without URL credentials, at most 2,048 characters. `pid` and the optional page, locale, timezone, referrer, campaign, profile, and metadata fields follow `/log/custom`. `title` is an optional source-page title (at most 2,048 characters). No `ev` or `unique` field is needed; every click is counted. API keys use the `events:write` scope. Success returns `{}`. + ### POST /log/hb This endpoint is used for heartbeat events. Heartbeat events are used to determine if the user session is still active. This allows you to see the 'Live Visitors' counter in the Dashboard panel. diff --git a/docs/content/docs/api/stats-v2.mdx b/docs/content/docs/api/stats-v2.mdx index af6abc06f..329cd6755 100644 --- a/docs/content/docs/api/stats-v2.mdx +++ b/docs/content/docs/api/stats-v2.mdx @@ -251,6 +251,17 @@ Notes: - `session_duration` and `bounce_rate` are not computable per dimension value; request them on the timeseries or summary endpoints. - When a custom-event filter (`event`, `event_metadata`) is applied, the base event set becomes custom events and `visitors` counts matched events (v1 semantics preserved). +### Outbound links breakdown + +Use the traffic breakdown endpoint with `dimension=outbound_link`. The supported metrics are `clicks` and `visitors` (both returned by default). This reads native outbound events, preserving pageview and custom-event totals. + +```bash +curl 'https://api.swetrix.com/v2/projects/YOUR_PROJECT_ID/traffic/breakdown?dimension=outbound_link&metrics=clicks,visitors&period=7d&sort=clicks:desc' \ + -H 'X-API-Key: YOUR_API_KEY' +``` + +Each row contains `value` (the destination URL, or `null` for an imported unknown destination), `clicks`, and `visitors`. Common date, traffic filters, sorting, limit, and offset parameters apply. The dimension is for breakdowns only; it cannot be used as a dashboard filter. The dimensions endpoint marks it with `breakdownOnly: true`. + ### GET /v2/projects/:pid/traffic/custom-events Counts of custom events in the selected period: `data: [{ "event": "signup", "count": 10 }, ...]` with `meta.total`, `limit`, `offset`. diff --git a/docs/content/docs/data-import.mdx b/docs/content/docs/data-import.mdx index 882119c28..47d62be95 100644 --- a/docs/content/docs/data-import.mdx +++ b/docs/content/docs/data-import.mdx @@ -400,3 +400,9 @@ Make sure you're uploading the correct file format for your provider. For Umami, ### Data not appearing in charts After a successful import, select a date range that covers the imported data's time period. The imported data indicator badge should appear to confirm it's included. + +### Plausible outbound links + +New Plausible imports convert `Outbound Link: Click` into native outbound events and preserve the export's `link_url` destination. They appear in **Traffic → Pages → Outbound links**, separately from custom events. Rows without a destination retain their click counts under **Unknown destination**. Imported visitor/session associations are reconstructed from aggregate data; click totals come directly from the export. + +Previously imported rows are not changed automatically. Older Swetrix importers discarded `link_url`, so the original export is required to restore destination URLs. After upgrading, remove the old import and import that export again; importing another copy without removing the original would duplicate its data. diff --git a/docs/content/docs/script-reference.mdx b/docs/content/docs/script-reference.mdx index 2ea5b1f10..b35de33b2 100644 --- a/docs/content/docs/script-reference.mdx +++ b/docs/content/docs/script-reference.mdx @@ -27,6 +27,7 @@ swetrix.init("YOUR_PROJECT_ID", { apiURL: "https://api.swetrix.com/log", profileId: "user-123", preloadSessionReplay: true, + events: { outboundLinks: true }, }); ``` @@ -39,6 +40,45 @@ swetrix.init("YOUR_PROJECT_ID", { | profileId | Optional profile ID for long-term user tracking (MAU/DAU). If set, it will be used for all pageviews and events unless overridden per-call. This allows you to track users across sessions and devices. To also link the visitor's previous anonymous activity to this profile, use [identify()](#identify) instead. | `undefined` | | preloadSessionReplay | Set to `true` to preload the session replay recorder after `init()`, or pass `{ rrwebUrl: "https://example.com/rrweb.min.js" }` to load it from a custom URL. Recording only starts after you call `startSessionReplay()`. | `undefined` | +## Automatic outbound link tracking + +Outbound link tracking is **disabled by default**. Enable it in `init()`: + +```javascript +swetrix.init("YOUR_PROJECT_ID", { + events: { outboundLinks: true }, +}); +swetrix.trackViews(); +``` + +Clicks on external HTTP(S) links appear under **Traffic → Pages → Outbound links**. The report shows click counts; its expanded view also shows visitors and supports search. Date, source-page, and traffic filters apply. Outbound clicks are stored separately from pageviews and custom events. On Swetrix Cloud, each native click counts as one event towards your plan; imported clicks do not count towards usage. + +The tracker handles nested elements, links added after initialization, keyboard activation, modified clicks, and middle-clicks without delaying navigation. Links on the current hostname (including its `www` equivalent) are ignored. Other subdomains are external unless you exclude them. Non-web links such as `mailto:`, `tel:`, and `javascript:` are ignored. + +Destination query strings and fragments are excluded by default. URL credentials are always removed. You can customize capture: + +```javascript +swetrix.init("YOUR_PROJECT_ID", { + events: { + outboundLinks: { + search: true, + hash: false, + ignoreDomains: ["app.example.com", "checkout.example.com"], + }, + }, +}); +``` + +`ignoreDomains` matches exact hostnames, case-insensitively, treating `www` as equivalent. Only enable `search` or `hash` when those parts of your destination URLs are appropriate to collect. Destination URLs longer than 2,048 characters after normalization are skipped. + +To exclude a link or a container of links, add `data-swetrix-ignore`: + +```html +Open link +``` + +Set `events.outboundLinks` to `false` or omit it to disable capture on the next page load. Call `swetrix.stopAutoCapture()` to remove the automatic capture listeners immediately. The existing `disabled`, `devMode`, and `respectDNT` options also apply. Set the options on your first `init()` call; subsequent calls reuse the same tracker instance. + ## track() @@ -341,6 +381,7 @@ Call `startSessionReplay()` after `init()`. If you set `preloadSessionReplay: tr ```javascript swetrix.init("YOUR_PROJECT_ID", { preloadSessionReplay: true, + events: { outboundLinks: true }, }); const replay = await swetrix.startSessionReplay({ @@ -359,6 +400,7 @@ import { init, startSessionReplay } from "swetrix"; init("YOUR_PROJECT_ID", { preloadSessionReplay: true, + events: { outboundLinks: true }, }); const replay = await startSessionReplay({ diff --git a/packages/tracker-js/README.md b/packages/tracker-js/README.md index 6df4d1824..c3dbc50d7 100644 --- a/packages/tracker-js/README.md +++ b/packages/tracker-js/README.md @@ -329,3 +329,16 @@ Contributions are welcome — feel free to [open an issue](https://github.com/sw ## License MIT + +## Outbound links + +Automatic outbound link tracking is opt-in: + +```javascript +swetrix.init('YOUR_PROJECT_ID', { + events: { outboundLinks: true }, +}) +swetrix.trackViews() +``` + +External HTTP(S) links appear in Traffic → Pages → Outbound links. Query strings and fragments are excluded by default. Pass `{ search: true, hash: true, ignoreDomains: ['app.example.com'] }` instead of `true` to customize capture. Add `data-swetrix-ignore` to links or their containers to exclude them. `swetrix.stopAutoCapture()` stops capture immediately. See the [tracking script reference](https://swetrix.com/docs/swetrix-js-reference#automatic-outbound-link-tracking) for details. diff --git a/packages/tracker-js/src/Lib.ts b/packages/tracker-js/src/Lib.ts index 2cb44c59e..970973bcc 100644 --- a/packages/tracker-js/src/Lib.ts +++ b/packages/tracker-js/src/Lib.ts @@ -60,7 +60,19 @@ declare global { */ export type Traits = Record +export interface OutboundLinksOptions { + search?: boolean + hash?: boolean + ignoreDomains?: readonly string[] +} + +export interface AutoCaptureOptions { + outboundLinks?: boolean | OutboundLinksOptions +} + export interface LibOptions { + events?: AutoCaptureOptions + /** * When set to `true`, localhost events will be sent to server. */ @@ -393,12 +405,57 @@ export class Lib { this.trackPathChange = this.trackPathChange.bind(this) this.heartbeat = this.heartbeat.bind(this) this.captureError = this.captureError.bind(this) + this.captureOutboundLink = this.captureOutboundLink.bind(this) + + if (this.options?.events?.outboundLinks && this.canTrack()) { + document.addEventListener('click', this.captureOutboundLink, true) + document.addEventListener('auxclick', this.captureOutboundLink, true) + } if (this.getSessionReplayPreloadOption()) { void this.preloadSessionReplay().catch(() => undefined) } } + stopAutoCapture(): void { + if (!isInBrowser()) return + document.removeEventListener('click', this.captureOutboundLink, true) + document.removeEventListener('auxclick', this.captureOutboundLink, true) + } + + private captureOutboundLink(event: MouseEvent): void { + if (!this.canTrack() || (event.type === 'click' ? event.button !== 0 : event.button !== 1)) return + + const path = event.composedPath?.() || [event.target] + const link = path.find((node) => node instanceof Element && node.matches('a[href], area[href]')) as + | Element + | undefined + if (!link || path.some((node) => node instanceof Element && node.hasAttribute('data-swetrix-ignore'))) return + + const option = this.options?.events?.outboundLinks + const config = typeof option === 'object' ? option : {} + + try { + const url = new URL(link.getAttribute('href')!, document.baseURI) + const hostname = url.hostname.replace(/^www\./, '') + if (!['http:', 'https:'].includes(url.protocol) || hostname === window.location.hostname.replace(/^www\./, '')) + return + if (config.ignoreDomains?.some((domain) => hostname === domain.toLowerCase().replace(/^www\./, ''))) return + + url.username = '' + url.password = '' + if (!config.search) url.search = '' + if (!config.hash) url.hash = '' + if (url.href.length > 2048) return + + void this.sendRequest('outbound', { + ...this.getEventContext(), + url: url.href, + title: document.title?.slice(0, 2048) || undefined, + }).catch(() => undefined) + } catch {} + } + captureError(event: ErrorEvent): void { if (typeof this.errorsOptions?.sampleRate === 'number' && this.errorsOptions.sampleRate >= Math.random()) { return @@ -487,8 +544,15 @@ export class Lib { return } - const data = { + await this.sendRequest('custom', { ...event, + ...this.getEventContext(), + profileId: event.profileId ?? this.options?.profileId, + }) + } + + private getEventContext() { + return { pid: this.projectID, pg: this.activePage || @@ -505,9 +569,8 @@ export class Lib { te: getUTMTerm(), co: getUTMContent(), qs: getQueryString(), - profileId: event.profileId ?? this.options?.profileId, + profileId: this.options?.profileId, } - await this.sendRequest('custom', data) } trackPageViews(options?: PageViewsOptions): PageActions { diff --git a/packages/tracker-js/src/index.ts b/packages/tracker-js/src/index.ts index 3802eb611..251e3e7cb 100644 --- a/packages/tracker-js/src/index.ts +++ b/packages/tracker-js/src/index.ts @@ -17,6 +17,8 @@ import { Traits, } from './Lib.js' +export type { AutoCaptureOptions, OutboundLinksOptions } from './Lib.js' + export let LIB_INSTANCE: Lib | null = null /** @@ -86,9 +88,11 @@ export function trackErrors(options?: ErrorOptions): ErrorActions { return LIB_INSTANCE.trackErrors(options) } -export function startSessionReplay( - options?: SessionReplayOptions, -): Promise { +export function stopAutoCapture(): void { + LIB_INSTANCE?.stopAutoCapture() +} + +export function startSessionReplay(options?: SessionReplayOptions): Promise { if (!LIB_INSTANCE) { return Promise.resolve(defaultSessionReplayActions) } diff --git a/packages/tracker-js/tests/outbound.test.ts b/packages/tracker-js/tests/outbound.test.ts new file mode 100644 index 000000000..dd66ab78c --- /dev/null +++ b/packages/tracker-js/tests/outbound.test.ts @@ -0,0 +1,170 @@ +import { Lib, LibOptions } from '../src/Lib' +import { setLocation } from './testUtils' + +describe('Outbound link capture', () => { + let instances: Lib[] + let request: jest.SpyInstance + + const create = (options: LibOptions = {}) => { + const lib = new Lib('testproject1', { devMode: true, ...options }) + instances.push(lib) + return lib + } + + const click = (href: string, options: MouseEventInit = {}, type = 'click') => { + const link = document.createElement('a') + link.href = href + link.innerHTML = 'Tickets' + link.addEventListener(type, (event) => event.preventDefault()) + document.body.appendChild(link) + link.firstElementChild!.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, composed: true, ...options }), + ) + return link + } + + beforeEach(() => { + instances = [] + setLocation({ hostname: 'example.com', pathname: '/article' }) + request = jest.spyOn(Lib.prototype as any, 'sendRequest').mockResolvedValue(undefined) + }) + + afterEach(() => { + instances.forEach((lib) => lib.stopAutoCapture()) + document.body.innerHTML = '' + jest.restoreAllMocks() + }) + + it.each([undefined, false])('is disabled when outboundLinks is %s', (outboundLinks) => { + create({ events: { outboundLinks } }) + click('https://tickets.example.org/event') + expect(request).not.toHaveBeenCalled() + }) + + it('captures a dynamically inserted nested link with page context and safe URL defaults', () => { + create({ events: { outboundLinks: true }, profileId: 'reader' }) + click('https://user:password@tickets.example.org/event?token=secret#section') + expect(request).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledWith( + 'outbound', + expect.objectContaining({ + pid: 'testproject1', + pg: '/article', + profileId: 'reader', + url: 'https://tickets.example.org/event', + }), + ) + }) + + it.each([ + '/inside', + '#section', + 'https://example.com/inside', + 'https://www.example.com/inside', + 'mailto:hello@example.org', + 'tel:123', + 'javascript:void(0)', + 'https://[invalid', + ])('ignores %s', (href) => { + create({ events: { outboundLinks: true } }) + click(href) + expect(request).not.toHaveBeenCalled() + }) + + it('supports explicit destination URL options and hostname exclusions', () => { + create({ events: { outboundLinks: { search: true, hash: true, ignoreDomains: ['APP.EXAMPLE.COM'] } } }) + click('https://www.app.example.com/start') + expect(request).not.toHaveBeenCalled() + click('https://tickets.example.org/event?id=4#seat') + expect(request).toHaveBeenCalledWith( + 'outbound', + expect.objectContaining({ url: 'https://tickets.example.org/event?id=4#seat' }), + ) + }) + + it('handles modified and middle clicks once and ignores right clicks', () => { + create({ events: { outboundLinks: true } }) + click('https://tickets.example.org/event', { ctrlKey: true }) + click('https://tickets.example.org/event', { button: 1 }, 'auxclick') + click('https://tickets.example.org/event', { button: 2 }, 'auxclick') + expect(request).toHaveBeenCalledTimes(2) + }) + + it('respects ignored links and ancestor containers', () => { + create({ events: { outboundLinks: true } }) + document.body.innerHTML = '' + document.querySelector('span')!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(request).not.toHaveBeenCalled() + }) + + it('captures links inside an open shadow root', () => { + create({ events: { outboundLinks: true } }) + const host = document.createElement('div') + document.body.appendChild(host) + const shadow = host.attachShadow({ mode: 'open' }) + shadow.innerHTML = 'Tickets' + shadow.querySelector('span')!.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })) + expect(request).toHaveBeenCalledTimes(1) + }) + + it('stops listeners without stopping manual custom events', async () => { + const lib = create({ events: { outboundLinks: true } }) + lib.stopAutoCapture() + lib.stopAutoCapture() + click('https://example.org') + expect(request).not.toHaveBeenCalled() + await lib.track({ ev: 'signup' }) + expect(request).toHaveBeenCalledWith('custom', expect.objectContaining({ ev: 'signup' })) + }) + + it('respects disabled tracking and DNT', () => { + create({ disabled: true, events: { outboundLinks: true } }) + click('https://example.org') + expect(request).not.toHaveBeenCalled() + Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '1' }) + create({ respectDNT: true, events: { outboundLinks: true } }) + click('https://example.org') + expect(request).not.toHaveBeenCalled() + Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: undefined }) + }) + + it('uses callback-adjusted source pages', () => { + const lib = create({ events: { outboundLinks: true } }) + const pages = lib.trackPageViews({ callback: () => ({ pg: '/articles/:slug' }) }) + request.mockClear() + click('https://example.org') + expect(request).toHaveBeenCalledWith('outbound', expect.objectContaining({ pg: '/articles/:slug' })) + pages.stop() + }) + + it('uses a keepalive request so clicks can survive navigation', () => { + request.mockRestore() + const originalFetch = global.fetch + const fetchMock = jest.fn().mockResolvedValue({}) + global.fetch = fetchMock + try { + create({ events: { outboundLinks: true }, apiURL: 'https://analytics.example.org/log' }) + click('https://tickets.example.org/event') + expect(fetchMock).toHaveBeenCalledWith( + 'https://analytics.example.org/log/outbound', + expect.objectContaining({ method: 'POST', keepalive: true }), + ) + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ + url: 'https://tickets.example.org/event', + pg: '/article', + }) + } finally { + global.fetch = originalFetch + } + }) + + it('skips oversized URLs and swallows network failures', async () => { + create({ events: { outboundLinks: true } }) + click(`https://example.org/${'x'.repeat(2048)}`) + expect(request).not.toHaveBeenCalled() + request.mockRejectedValueOnce(new Error('offline')) + click('https://example.org') + await Promise.resolve() + expect(request).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/lib/models/Entry.ts b/web/app/lib/models/Entry.ts index 571fdfea9..27df3dbe3 100644 --- a/web/app/lib/models/Entry.ts +++ b/web/app/lib/models/Entry.ts @@ -1,6 +1,7 @@ export interface Entry { name: string | null count: number + visitors?: number cc?: string rgc?: string } diff --git a/web/app/lib/models/Usageinfo.ts b/web/app/lib/models/Usageinfo.ts index 58b364c62..622d29e2a 100644 --- a/web/app/lib/models/Usageinfo.ts +++ b/web/app/lib/models/Usageinfo.ts @@ -2,6 +2,8 @@ interface UsageInfoBreakdown { total: number traffic: number customEvents: number + outboundLinks?: number + outboundLinksPerc?: number captcha: number errors: number trafficPerc: number diff --git a/web/app/pages/Project/Settings/components/DeleteDataModal.tsx b/web/app/pages/Project/Settings/components/DeleteDataModal.tsx index 14300083a..40a3e3691 100644 --- a/web/app/pages/Project/Settings/components/DeleteDataModal.tsx +++ b/web/app/pages/Project/Settings/components/DeleteDataModal.tsx @@ -1,5 +1,6 @@ import { CheckIcon, + ArrowSquareOutIcon, CursorClickIcon, EyeIcon, GaugeIcon, @@ -55,6 +56,11 @@ const EVENT_TYPES: EventType[] = [ labelKey: 'project.settings.deleteData.types.custom_event', Icon: CursorClickIcon, }, + { + key: 'outbound', + labelKey: 'project.outboundLinks', + Icon: ArrowSquareOutIcon, + }, { key: 'error', labelKey: 'project.settings.deleteData.types.error', diff --git a/web/app/pages/Project/View/ViewProject.helpers.tsx b/web/app/pages/Project/View/ViewProject.helpers.tsx index 087d76397..921b8cce8 100644 --- a/web/app/pages/Project/View/ViewProject.helpers.tsx +++ b/web/app/pages/Project/View/ViewProject.helpers.tsx @@ -2330,6 +2330,7 @@ const typeNameMapping = (t: typeof i18next.t) => ({ city: t('project.mapping.ct'), page: t('project.mapping.pg'), title: t('project.mapping.title'), + outbound_link: t('project.outboundLinks'), query: t('project.seo.query'), entry_page: t('project.entryPages'), exit_page: t('project.exitPages'), diff --git a/web/app/pages/Project/View/v2/BreakdownPanel.tsx b/web/app/pages/Project/View/v2/BreakdownPanel.tsx index e5ebd4a86..a7e9d988e 100644 --- a/web/app/pages/Project/View/v2/BreakdownPanel.tsx +++ b/web/app/pages/Project/View/v2/BreakdownPanel.tsx @@ -19,6 +19,12 @@ export interface BreakdownSubTab { id: string label: string dimension?: string + primaryMetric?: string + metrics?: string[] + valuesHeaderName?: string + disableRowClick?: boolean + emptyMessage?: string + detailsExtraColumns?: PanelProps['detailsExtraColumns'] render?: () => React.ReactNode versionsDimension?: 'browser_version' | 'os_version' versionsParentField?: 'browser' | 'os' @@ -111,11 +117,15 @@ export const BreakdownPanel = ({ const isSentinel = !activeSubTab.dimension - const resolvedSort = sort ?? `${primaryMetric}:desc` + const resolvedPrimaryMetric = activeSubTab.primaryMetric ?? primaryMetric + const resolvedMetrics = activeSubTab.metrics ?? metrics + const resolvedSort = activeSubTab.primaryMetric + ? `${resolvedPrimaryMetric}:desc` + : (sort ?? `${primaryMetric}:desc`) const query = useBreakdownDetailsQuery(dataType, { dimension: activeSubTab.dimension || '', - metrics, + metrics: resolvedMetrics, measure, sort: resolvedSort, enabled: hasBeenInView && !isSentinel, @@ -123,7 +133,7 @@ export const BreakdownPanel = ({ const versionsQuery = useBreakdownDetailsQuery(dataType, { dimension: activeSubTab.versionsDimension || '', - metrics, + metrics: resolvedMetrics, measure, sort: resolvedSort, enabled: hasBeenInView && Boolean(activeSubTab.versionsDimension), @@ -131,12 +141,21 @@ export const BreakdownPanel = ({ const entries = useMemo(() => { const mapped = mapBreakdownRows( - query.data?.pages.flatMap((page) => page.data), - primaryMetric, - metrics, + query.isPlaceholderData + ? undefined + : query.data?.pages.flatMap((page) => page.data), + resolvedPrimaryMetric, + resolvedMetrics, ) return transformEntries ? transformEntries(mapped, activeSubTabId) : mapped - }, [query.data, primaryMetric, metrics, transformEntries, activeSubTabId]) + }, [ + query.data, + query.isPlaceholderData, + resolvedPrimaryMetric, + resolvedMetrics, + transformEntries, + activeSubTabId, + ]) const versionData = useMemo(() => { if (!activeSubTab.versionsDimension || !versionsQuery.data) { @@ -175,7 +194,10 @@ export const BreakdownPanel = ({ // Panels below the fold only start fetching once scrolled into view, which // leaves the query pending rather than loading — both mean "no rows yet". - const isLoading = query.isLoading || (!hasBeenInView && !query.data) + const isLoading = + query.isLoading || + query.isPlaceholderData || + (!hasBeenInView && !query.data) const renderError = () => (
@@ -206,7 +228,19 @@ export const BreakdownPanel = ({ name={name} data={entries} isLoading={isLoading} - customRenderer={query.isError ? renderError : undefined} + customRenderer={ + query.isError + ? renderError + : !isLoading && entries.length === 0 && activeSubTab.emptyMessage + ? () => ( +
+ + {activeSubTab.emptyMessage} + +
+ ) + : undefined + } tabs={containerTabs} onTabChange={onSubTabChange} activeTabId={activeSubTabId} @@ -231,13 +265,15 @@ export const BreakdownPanel = ({ getVersionFilterLink(parent, version, activeSubTabId) : undefined } - valuesHeaderName={valuesHeaderName} + valuesHeaderName={activeSubTab.valuesHeaderName ?? valuesHeaderName} highlightColour={highlightColour} - detailsExtraColumns={detailsExtraColumns} + detailsExtraColumns={ + activeSubTab.detailsExtraColumns ?? detailsExtraColumns + } rowTooltipRenderer={rowTooltipRenderer} rowTooltipFollowCursor={rowTooltipFollowCursor} hidePercentageInDetails={hidePercentageInDetails} - disableRowClick={disableRowClick} + disableRowClick={activeSubTab.disableRowClick ?? disableRowClick} dataLoading={query.isFetching && !query.isFetchingNextPage} isRefetching={ query.isFetching && !query.isLoading && !query.isFetchingNextPage diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index 09d8ab823..94135e59e 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -8,7 +8,12 @@ import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _map from 'lodash/map' import _some from 'lodash/some' -import { ProhibitIcon, EyeIcon, DownloadIcon } from '@phosphor-icons/react' +import { + ProhibitIcon, + EyeIcon, + DownloadIcon, + ArrowSquareOutIcon, +} from '@phosphor-icons/react' import React, { useState, useEffect, @@ -27,6 +32,7 @@ import * as v2api from '~/api/v2/endpoints' import { useRevenueProxy } from '~/hooks/useAnalyticsProxy' import { useAnnotations } from '~/hooks/useAnnotations' import { + useBreakdownQuery, useCompareSummaryQuery, useCompareTimeseriesQuery, useCustomEventsQuery, @@ -363,14 +369,34 @@ const TrafficViewInner = ({ const summaryLoaded = Boolean(summaryQuery.data) const customsLoaded = Boolean(customEventsQuery.data) + const outboundPresenceQuery = useBreakdownQuery('traffic', { + dimension: 'outbound_link', + metrics: ['clicks'], + limit: 1, + enabled: + summaryLoaded && + customsLoaded && + !overall.current?.all && + _isEmpty(customs), + }) + const isPanelsDataEmptyRaw = summaryLoaded && customsLoaded && !overall.current?.all && !overall.current?.unique && - _isEmpty(customs) + _isEmpty(customs) && + outboundPresenceQuery.isSuccess && + _isEmpty(outboundPresenceQuery.data?.data) - if (summaryLoaded && customsLoaded && !isPanelsDataEmptyRaw) { + if ( + summaryLoaded && + customsLoaded && + (overall.current?.all || + overall.current?.unique || + !_isEmpty(customs) || + !_isEmpty(outboundPresenceQuery.data?.data)) + ) { hasShownContentRef.current = true } @@ -755,6 +781,24 @@ const TrafficViewInner = ({ dimension: 'exit_page', }, { id: 'host', label: t('project.panelTabs.host'), dimension: 'host' }, + { + id: 'outbound_link', + label: t('project.outboundLinks'), + dimension: 'outbound_link', + primaryMetric: 'clicks', + metrics: ['clicks', 'visitors'], + valuesHeaderName: t('project.outboundClicks'), + detailsExtraColumns: [ + { + header: t('project.visitors'), + sortLabel: t('project.visitors'), + render: (entry) => Number(entry.visitors || 0).toLocaleString(), + getSortValue: (entry) => Number(entry.visitors || 0), + }, + ], + disableRowClick: true, + emptyMessage: t('project.outboundLinksEmpty'), + }, ], [t], ) @@ -883,13 +927,37 @@ const TrafficViewInner = ({ if (!entryName) { return ( - {subTabId === 'host' - ? t('project.unknownHost') - : t('common.notSet')} + {subTabId === 'outbound_link' + ? t('project.unknownDestination') + : subTabId === 'host' + ? t('project.unknownHost') + : t('common.notSet')} ) } + if (subTabId === 'outbound_link') { + let url: URL + try { + url = new URL(entryName) + } catch { + return entryName + } + if (!['http:', 'https:'].includes(url.protocol)) return entryName + return ( + event.stopPropagation()} + > + + {entryName} + + ) + } + if (subTabId === 'title') { return entryName } @@ -989,6 +1057,7 @@ const TrafficViewInner = ({ 'locale', 'page', 'title', + 'outbound_link', 'entry_page', 'exit_page', 'host', @@ -1017,7 +1086,10 @@ const TrafficViewInner = ({ ...common, dimension, limit: 100, - sort: 'visitors:desc', + sort: + dimension === 'outbound_link' + ? 'clicks:desc' + : 'visitors:desc', }) .catch(() => null), ), @@ -1027,7 +1099,10 @@ const TrafficViewInner = ({ dimensions.forEach((dimension, index) => { const result = results[index] if (result && result.data.length > 0) { - data[dimension] = mapBreakdownRows(result.data) + data[dimension] = mapBreakdownRows( + result.data, + dimension === 'outbound_link' ? 'clicks' : 'visitors', + ) } }) diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index c0e38455b..f27242621 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -2098,6 +2098,20 @@ const UserSettings = () => { })}
+
+
+ + {t('billing.outboundLinks', { + quantity: usageInfo.outboundLinks || 0, + percentage: usageInfo.outboundLinksPerc || 0, + })} + +
{ lightColour: '#c026d3', darkColour: '#a21caf', }, + { + value: maxEventsCount + ? Math.min( + 100, + ((usageInfo.outboundLinks || 0) / + maxEventsCount) * + 100, + ) + : 0, + lightColour: '#0d9488', + darkColour: '#0f766e', + }, { value: Math.min( 100, diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 7f6245fec..08a81cfd3 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -843,6 +843,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const result = await serverFetch(request, `data-import/${id}/upload`, { method: 'POST', body: proxyForm, + timeoutMs: 240000, }) if (result.error) { diff --git a/web/app/ui/TrackingSetup.tsx b/web/app/ui/TrackingSetup.tsx index c53b7ce6b..412c161eb 100644 --- a/web/app/ui/TrackingSetup.tsx +++ b/web/app/ui/TrackingSetup.tsx @@ -69,19 +69,20 @@ import { Badge } from '~/ui/Badge' import CodeBlock from '~/ui/CodeBlock' import CopyButton from '~/ui/CopyButton' import { Text } from '~/ui/Text' +import { Switch } from '~/ui/Switch' import { cn } from '~/utils/generic' const API_URL_WITHOUT_TRAILING_SLASH = API_URL.endsWith('/') ? API_URL.slice(0, -1) : API_URL -const getSnippet = (pid: string) => { +const getSnippet = (pid: string, outboundLinks = false) => { if (isSelfhosted) { return ` @@ -400,19 +401,29 @@ const PlatformIcon = ({ ) -const getNpmSnippet = (pid: string) => +const getInitOptions = (outboundLinks: boolean) => { + const options = [ + ...(isSelfhosted + ? [`apiURL: '${API_URL_WITHOUT_TRAILING_SLASH}/v1/log'`] + : []), + ...(outboundLinks ? ['events: { outboundLinks: true }'] : []), + ] + return options.length ? `, { ${options.join(', ')} }` : '' +} + +const getNpmSnippet = (pid: string, outboundLinks = false) => `import * as Swetrix from 'swetrix' -Swetrix.init('${pid}'${isSelfhosted ? `, {\n apiURL: '${API_URL_WITHOUT_TRAILING_SLASH}/v1/log',\n}` : ''}) +Swetrix.init('${pid}'${getInitOptions(outboundLinks)}) Swetrix.trackViews()` -const getGtmSnippet = (pid: string) => +const getGtmSnippet = (pid: string, outboundLinks = false) => `