mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
chore: improve event flow (#14266)
This commit is contained in:
@@ -45,6 +45,7 @@ import { QuotaModule } from './core/quota';
|
||||
import { SelfhostModule } from './core/selfhost';
|
||||
import { StorageModule } from './core/storage';
|
||||
import { SyncModule } from './core/sync';
|
||||
import { TelemetryModule } from './core/telemetry';
|
||||
import { UserModule } from './core/user';
|
||||
import { VersionModule } from './core/version';
|
||||
import { WorkspaceModule } from './core/workspaces';
|
||||
@@ -175,7 +176,7 @@ export function buildAppModule(env: Env) {
|
||||
// renderer server only
|
||||
.useIf(() => env.flavors.renderer, DocRendererModule)
|
||||
// sync server only
|
||||
.useIf(() => env.flavors.sync, SyncModule)
|
||||
.useIf(() => env.flavors.sync, SyncModule, TelemetryModule)
|
||||
// graphql server only
|
||||
.useIf(
|
||||
() => env.flavors.graphql,
|
||||
@@ -191,6 +192,7 @@ export function buildAppModule(env: Env) {
|
||||
OAuthModule,
|
||||
CalendarModule,
|
||||
CustomerIoModule,
|
||||
TelemetryModule,
|
||||
CommentModule,
|
||||
AccessTokenModule,
|
||||
QueueDashboardModule
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'ava';
|
||||
|
||||
import {
|
||||
cleanTelemetryEvent,
|
||||
sanitizeParams,
|
||||
sanitizeUserProperties,
|
||||
} from '../cleaner';
|
||||
import { TelemetryDeduper } from '../deduper';
|
||||
|
||||
test('sanitizeParams applies renames, drops, and normalization', t => {
|
||||
const params = {
|
||||
page: 'home',
|
||||
status: true,
|
||||
docId: 'doc-1',
|
||||
other: { foo: 'bar' },
|
||||
nested: { value: 'x' },
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const sanitized = sanitizeParams(params);
|
||||
|
||||
t.is(sanitized.ui_page, 'home');
|
||||
t.is(sanitized.result, 'success');
|
||||
t.is(sanitized.nested_setting_value, 'x');
|
||||
t.is(sanitized.enabled, 'off');
|
||||
t.false('doc_id' in sanitized);
|
||||
t.false(Object.keys(sanitized).some(key => key.includes('other')));
|
||||
});
|
||||
|
||||
test('sanitizeUserProperties filters and maps values', t => {
|
||||
const props = {
|
||||
appVersion: '1.2.3',
|
||||
pro: true,
|
||||
$email: 'test@example.com',
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
const sanitized = sanitizeUserProperties(props);
|
||||
|
||||
t.deepEqual(sanitized, {
|
||||
app_version: '1.2.3',
|
||||
plan_tier: 'pro',
|
||||
is_mobile: '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('cleanTelemetryEvent maps page view metadata', t => {
|
||||
const cleaned = cleanTelemetryEvent(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
eventName: 'track_pageview',
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-1',
|
||||
params: {
|
||||
location: 'https://example.com/docs?tab=1',
|
||||
title: 'Example',
|
||||
},
|
||||
context: {
|
||||
url: 'https://example.com/docs?tab=1',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestampMicros: 123456,
|
||||
}
|
||||
);
|
||||
|
||||
t.truthy(cleaned);
|
||||
t.is(cleaned?.eventName, 'page_view');
|
||||
t.is(cleaned?.params.page_location, 'https://example.com/docs?tab=1');
|
||||
t.is(cleaned?.params.page_path, '/docs?tab=1');
|
||||
t.is(cleaned?.params.page_title, 'Example');
|
||||
});
|
||||
|
||||
test('TelemetryDeduper drops duplicates within ttl', t => {
|
||||
const deduper = new TelemetryDeduper(1000, 10);
|
||||
const key = 'client-1:event-1';
|
||||
|
||||
t.false(deduper.isDuplicate(key, 0));
|
||||
t.true(deduper.isDuplicate(key, 500));
|
||||
t.false(deduper.isDuplicate(key, 1500));
|
||||
});
|
||||
444
packages/backend/server/src/core/telemetry/cleaner.ts
Normal file
444
packages/backend/server/src/core/telemetry/cleaner.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { TelemetryEvent } from './types';
|
||||
|
||||
export type Scalar = string | number;
|
||||
|
||||
export type CleanedTelemetryEvent = {
|
||||
clientId: string;
|
||||
userId?: string;
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
params: Record<string, Scalar>;
|
||||
userProperties: Record<string, string>;
|
||||
timestampMicros: number;
|
||||
};
|
||||
|
||||
const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
|
||||
const PARAM_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
|
||||
const USER_PROP_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,23}$/;
|
||||
|
||||
const EVENT_NAME_ALIAS: Record<string, string> = {
|
||||
track_pageview: 'page_view',
|
||||
};
|
||||
|
||||
const PARAM_RENAME_MAP = new Map<string, string>([
|
||||
['page', 'ui_page'],
|
||||
['segment', 'ui_segment'],
|
||||
['module', 'ui_module'],
|
||||
['arg', 'ui_arg'],
|
||||
['control', 'ui_control'],
|
||||
['option', 'ui_option'],
|
||||
['key', 'setting_key'],
|
||||
['value', 'setting_value'],
|
||||
['docId', 'doc_id'],
|
||||
['workspaceId', 'workspace_id'],
|
||||
['serverId', 'server_id'],
|
||||
['docType', 'doc_type'],
|
||||
['docCount', 'doc_count'],
|
||||
['unreadCount', 'unread_count'],
|
||||
['withAttachment', 'with_attachment'],
|
||||
['withMention', 'with_mention'],
|
||||
['appName', 'app_name'],
|
||||
['recurring', 'billing_cycle'],
|
||||
['plan', 'plan_name'],
|
||||
['time', 'duration_ms'],
|
||||
['error', 'error_code'],
|
||||
['status', 'result'],
|
||||
['success', 'result'],
|
||||
['to', 'target'],
|
||||
['on', 'enabled'],
|
||||
['pageLocation', 'page_location'],
|
||||
['pageReferrer', 'page_referrer'],
|
||||
['pagePath', 'page_path'],
|
||||
['pageTitle', 'page_title'],
|
||||
]);
|
||||
|
||||
const USER_PROP_RENAME_MAP = new Map<string, string>([
|
||||
['appVersion', 'app_version'],
|
||||
['editorVersion', 'editor_version'],
|
||||
['environment', 'environment'],
|
||||
['isDesktop', 'is_desktop'],
|
||||
['isMobile', 'is_mobile'],
|
||||
['distribution', 'distribution'],
|
||||
['isSelfHosted', 'is_self_hosted'],
|
||||
['ai', 'ai_enabled'],
|
||||
['pro', 'plan_tier'],
|
||||
['quota', 'quota_tier'],
|
||||
]);
|
||||
|
||||
const DROP_PARAM_SEGMENTS = new Set(['other', 'instruction', 'operation']);
|
||||
const DROP_MAPPED_PARAMS = new Set(['doc_id', 'workspace_id', 'server_id']);
|
||||
|
||||
const PRIORITY_KEYS = new Set([
|
||||
'event_id',
|
||||
'session_id',
|
||||
'ui_page',
|
||||
'ui_segment',
|
||||
'ui_module',
|
||||
'ui_control',
|
||||
'ui_option',
|
||||
'ui_arg',
|
||||
'type',
|
||||
'method',
|
||||
'mode',
|
||||
'plan_name',
|
||||
'billing_cycle',
|
||||
'role',
|
||||
'result',
|
||||
'error_code',
|
||||
'category',
|
||||
'doc_type',
|
||||
'item',
|
||||
'action',
|
||||
'target',
|
||||
'enabled',
|
||||
'setting_key',
|
||||
'setting_value',
|
||||
'duration_ms',
|
||||
'doc_count',
|
||||
'unread_count',
|
||||
'with_attachment',
|
||||
'with_mention',
|
||||
'page_location',
|
||||
'page_path',
|
||||
'page_referrer',
|
||||
'page_title',
|
||||
]);
|
||||
|
||||
export function cleanTelemetryEvent(
|
||||
event: TelemetryEvent,
|
||||
{
|
||||
userId,
|
||||
timestampMicros,
|
||||
maxParams = 25,
|
||||
}: {
|
||||
userId?: string;
|
||||
timestampMicros: number;
|
||||
maxParams?: number;
|
||||
}
|
||||
): CleanedTelemetryEvent | null {
|
||||
if (!event || event.schemaVersion !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!event.clientId ||
|
||||
typeof event.clientId !== 'string' ||
|
||||
!event.eventId ||
|
||||
typeof event.eventId !== 'string' ||
|
||||
!event.eventName ||
|
||||
typeof event.eventName !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mappedEventName = mapEventName(event.eventName);
|
||||
if (!EVENT_NAME_RE.test(mappedEventName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contextParams = buildContextParams(event.context);
|
||||
const baseParams = isPlainObject(event.params) ? event.params : {};
|
||||
const mergedParams: Record<string, unknown> = {
|
||||
...baseParams,
|
||||
...contextParams,
|
||||
eventId: event.eventId,
|
||||
};
|
||||
if (event.sessionId) {
|
||||
mergedParams.sessionId = event.sessionId;
|
||||
}
|
||||
|
||||
const contextUserProps = buildContextUserProps(event.context);
|
||||
const baseUserProps = isPlainObject(event.userProperties)
|
||||
? event.userProperties
|
||||
: {};
|
||||
const mergedUserProps = {
|
||||
...contextUserProps,
|
||||
...baseUserProps,
|
||||
};
|
||||
|
||||
const sanitizedUserProps = sanitizeUserProperties(mergedUserProps);
|
||||
|
||||
const sanitizedParams =
|
||||
mappedEventName === 'page_view'
|
||||
? sanitizePageViewParams(mergedParams, event.context, maxParams)
|
||||
: sanitizeParams(mergedParams, maxParams);
|
||||
|
||||
if (!Object.keys(sanitizedParams).length) {
|
||||
sanitizedParams.event_id = event.eventId;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: event.clientId,
|
||||
userId: event.userId ?? userId,
|
||||
eventId: event.eventId,
|
||||
eventName: mappedEventName,
|
||||
params: sanitizedParams,
|
||||
userProperties: sanitizedUserProps,
|
||||
timestampMicros,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContextParams(context?: TelemetryEvent['context']) {
|
||||
if (!context) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {};
|
||||
if (context.url) {
|
||||
params.pageLocation = context.url;
|
||||
}
|
||||
if (context.referrer) {
|
||||
params.pageReferrer = context.referrer;
|
||||
}
|
||||
if (context.locale) {
|
||||
params.locale = context.locale;
|
||||
}
|
||||
if (context.timezone) {
|
||||
params.timezone = context.timezone;
|
||||
}
|
||||
if (context.channel) {
|
||||
params.channel = context.channel;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildContextUserProps(context?: TelemetryEvent['context']) {
|
||||
if (!context) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const props: Record<string, unknown> = {};
|
||||
if (context.appVersion) {
|
||||
props.appVersion = context.appVersion;
|
||||
}
|
||||
if (context.editorVersion) {
|
||||
props.editorVersion = context.editorVersion;
|
||||
}
|
||||
if (context.environment) {
|
||||
props.environment = context.environment;
|
||||
}
|
||||
if (context.distribution) {
|
||||
props.distribution = context.distribution;
|
||||
}
|
||||
if (context.isDesktop !== undefined) {
|
||||
props.isDesktop = context.isDesktop;
|
||||
}
|
||||
if (context.isMobile !== undefined) {
|
||||
props.isMobile = context.isMobile;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
function sanitizePageViewParams(
|
||||
input: Record<string, unknown>,
|
||||
context: TelemetryEvent['context'] | undefined,
|
||||
maxParams: number
|
||||
) {
|
||||
const customParams = { ...input };
|
||||
const rawLocation = customParams.location;
|
||||
const rawTitle = customParams.title ?? customParams.pageTitle;
|
||||
delete customParams.location;
|
||||
delete customParams.title;
|
||||
delete customParams.pageTitle;
|
||||
|
||||
const { pageLocation, pagePath, pageTitle } = resolvePageViewMeta(
|
||||
rawLocation,
|
||||
context?.url,
|
||||
rawTitle
|
||||
);
|
||||
|
||||
const sanitized = sanitizeParams(customParams, Math.max(maxParams - 3, 1));
|
||||
const merged: Record<string, Scalar> = { ...sanitized };
|
||||
if (pageLocation) {
|
||||
merged.page_location = pageLocation;
|
||||
}
|
||||
if (pagePath) {
|
||||
merged.page_path = pagePath;
|
||||
}
|
||||
if (pageTitle) {
|
||||
merged.page_title = pageTitle;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolvePageViewMeta(
|
||||
locationValue: unknown,
|
||||
contextUrl: string | undefined,
|
||||
titleValue: unknown
|
||||
) {
|
||||
let pageLocation =
|
||||
typeof locationValue === 'string' ? locationValue : contextUrl;
|
||||
let pagePath: string | undefined;
|
||||
|
||||
if (pageLocation) {
|
||||
try {
|
||||
const url = contextUrl
|
||||
? new URL(pageLocation, contextUrl)
|
||||
: new URL(pageLocation);
|
||||
pagePath = url.pathname + url.search;
|
||||
if (!pageLocation.startsWith('http')) {
|
||||
pageLocation = url.toString();
|
||||
}
|
||||
} catch {
|
||||
if (pageLocation.startsWith('/')) {
|
||||
pagePath = pageLocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitle = typeof titleValue === 'string' ? titleValue : undefined;
|
||||
|
||||
return { pageLocation, pagePath, pageTitle };
|
||||
}
|
||||
|
||||
function toSnakeCase(input: string): string {
|
||||
return input
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, '$1_$2')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-zA-Z0-9_]/g, '_')
|
||||
.replace(/__+/g, '_')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function toScalar(value: unknown): Scalar | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 100 ? value.slice(0, 100) : value;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized.length > 100 ? serialized.slice(0, 100) : serialized;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeValue(key: string, value: unknown): Scalar | undefined {
|
||||
if (key === 'result' && typeof value === 'boolean') {
|
||||
return value ? 'success' : 'failure';
|
||||
}
|
||||
if (key === 'enabled' && typeof value === 'boolean') {
|
||||
return value ? 'on' : 'off';
|
||||
}
|
||||
return toScalar(value);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
if (value instanceof Date) return false;
|
||||
if (Array.isArray(value)) return false;
|
||||
return Object.getPrototypeOf(value) === Object.prototype;
|
||||
}
|
||||
|
||||
function flattenProps(
|
||||
input: Record<string, unknown>,
|
||||
prefix = ''
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (isPlainObject(value)) {
|
||||
Object.assign(out, flattenProps(value, path));
|
||||
} else {
|
||||
out[path] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapParamKey(path: string): string {
|
||||
const segments = path.split('.');
|
||||
const mappedSegments = segments.map(
|
||||
segment => PARAM_RENAME_MAP.get(segment) ?? segment
|
||||
);
|
||||
return toSnakeCase(mappedSegments.join('_'));
|
||||
}
|
||||
|
||||
function mapEventName(name: string): string {
|
||||
const alias = EVENT_NAME_ALIAS[name];
|
||||
return toSnakeCase(alias ?? name);
|
||||
}
|
||||
|
||||
function shouldDropPath(path: string): boolean {
|
||||
const segments = path.split('.');
|
||||
return segments.some(segment => DROP_PARAM_SEGMENTS.has(segment));
|
||||
}
|
||||
|
||||
export function sanitizeParams(
|
||||
input: Record<string, unknown>,
|
||||
maxParams = 25
|
||||
): Record<string, Scalar> {
|
||||
const flattened = flattenProps(input);
|
||||
const mappedEntries: Array<[string, Scalar]> = [];
|
||||
|
||||
for (const [path, value] of Object.entries(flattened)) {
|
||||
if (shouldDropPath(path)) continue;
|
||||
|
||||
const mappedKey = mapParamKey(path);
|
||||
if (!mappedKey || !PARAM_NAME_RE.test(mappedKey)) continue;
|
||||
if (DROP_MAPPED_PARAMS.has(mappedKey)) continue;
|
||||
|
||||
const normalized = normalizeValue(mappedKey, value);
|
||||
if (normalized === undefined) continue;
|
||||
|
||||
mappedEntries.push([mappedKey, normalized]);
|
||||
}
|
||||
|
||||
mappedEntries.sort((a, b) => {
|
||||
const aPriority = PRIORITY_KEYS.has(a[0]);
|
||||
const bPriority = PRIORITY_KEYS.has(b[0]);
|
||||
if (aPriority === bPriority) return 0;
|
||||
return aPriority ? -1 : 1;
|
||||
});
|
||||
|
||||
const out: Record<string, Scalar> = {};
|
||||
for (const [key, value] of mappedEntries) {
|
||||
if (Object.keys(out).length >= maxParams) break;
|
||||
if (key in out) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapUserPropKey(key: string): string | undefined {
|
||||
if (key.startsWith('$')) return undefined;
|
||||
const mapped = USER_PROP_RENAME_MAP.get(key) ?? key;
|
||||
return toSnakeCase(mapped);
|
||||
}
|
||||
|
||||
export function sanitizeUserProperties(
|
||||
props: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
const mappedKey = mapUserPropKey(key);
|
||||
if (!mappedKey || !USER_PROP_NAME_RE.test(mappedKey)) continue;
|
||||
|
||||
let mappedValue = value;
|
||||
if (key === 'pro' && typeof value === 'boolean') {
|
||||
mappedValue = value ? 'pro' : 'free';
|
||||
}
|
||||
|
||||
const scalar = toScalar(mappedValue);
|
||||
if (scalar === undefined) continue;
|
||||
|
||||
const stringValue = String(scalar);
|
||||
sanitized[mappedKey] =
|
||||
stringValue.length > 36 ? stringValue.slice(0, 36) : stringValue;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
51
packages/backend/server/src/core/telemetry/config.ts
Normal file
51
packages/backend/server/src/core/telemetry/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
export interface TelemetryConfig {
|
||||
allowedOrigin: ConfigItem<string[]>;
|
||||
ga4: {
|
||||
measurementId: ConfigItem<string>;
|
||||
apiSecret: ConfigItem<string>;
|
||||
};
|
||||
dedupe: {
|
||||
ttlHours: ConfigItem<number>;
|
||||
maxEntries: ConfigItem<number>;
|
||||
};
|
||||
batch: {
|
||||
maxEvents: ConfigItem<number>;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
telemetry: TelemetryConfig;
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('telemetry', {
|
||||
allowedOrigin: {
|
||||
desc: 'Allowed origins for telemetry collection.',
|
||||
default: ['localhost', '127.0.0.1'],
|
||||
},
|
||||
'ga4.measurementId': {
|
||||
desc: 'GA4 Measurement ID for Measurement Protocol.',
|
||||
default: '',
|
||||
env: 'GA4_MEASUREMENT_ID',
|
||||
},
|
||||
'ga4.apiSecret': {
|
||||
desc: 'GA4 API secret for Measurement Protocol.',
|
||||
default: '',
|
||||
env: 'GA4_API_SECRET',
|
||||
},
|
||||
'dedupe.ttlHours': {
|
||||
desc: 'Telemetry dedupe TTL in hours.',
|
||||
default: 24,
|
||||
},
|
||||
'dedupe.maxEntries': {
|
||||
desc: 'Telemetry dedupe max entries.',
|
||||
default: 100000,
|
||||
},
|
||||
'batch.maxEvents': {
|
||||
desc: 'Max events per telemetry batch.',
|
||||
default: 25,
|
||||
},
|
||||
});
|
||||
66
packages/backend/server/src/core/telemetry/controller.ts
Normal file
66
packages/backend/server/src/core/telemetry/controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Body, Controller, Options, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { BadRequest, Throttle, UseNamedGuard } from '../../base';
|
||||
import type { CurrentUser as CurrentUserType } from '../auth';
|
||||
import { Public } from '../auth';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { TelemetryService } from './service';
|
||||
import { TelemetryAck, type TelemetryBatch } from './types';
|
||||
|
||||
@Public()
|
||||
@UseNamedGuard('version')
|
||||
@Throttle('default')
|
||||
@Controller('/api/telemetry')
|
||||
export class TelemetryController {
|
||||
constructor(private readonly telemetry: TelemetryService) {}
|
||||
|
||||
@Options('/collect')
|
||||
collectOptions(@Req() req: Request, @Res() res: Response) {
|
||||
const origin = req.headers.origin;
|
||||
const referer = req.headers.referer;
|
||||
if (!this.telemetry.isOriginAllowed(origin, referer)) {
|
||||
throw new BadRequest(`Invalid origin: ${origin}, referer: ${referer}`);
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.header({
|
||||
...this.telemetry.getCorsHeaders(origin),
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, x-affine-version',
|
||||
})
|
||||
.send();
|
||||
}
|
||||
|
||||
@Post('/collect')
|
||||
async collect(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body() batch: TelemetryBatch,
|
||||
@CurrentUser() user?: CurrentUserType
|
||||
): Promise<TelemetryAck> {
|
||||
const origin = req.headers.origin;
|
||||
const referer = req.headers.referer;
|
||||
if (!this.telemetry.isOriginAllowed(origin, referer)) {
|
||||
throw new BadRequest('Invalid origin: ' + origin);
|
||||
}
|
||||
|
||||
res.header({
|
||||
...this.telemetry.getCorsHeaders(origin),
|
||||
});
|
||||
|
||||
const patchedBatch =
|
||||
user && Array.isArray(batch?.events)
|
||||
? {
|
||||
...batch,
|
||||
events: batch.events.map(event => ({
|
||||
...event,
|
||||
userId: event.userId ?? user.id,
|
||||
})),
|
||||
}
|
||||
: batch;
|
||||
|
||||
return this.telemetry.collectBatch(patchedBatch);
|
||||
}
|
||||
}
|
||||
31
packages/backend/server/src/core/telemetry/deduper.ts
Normal file
31
packages/backend/server/src/core/telemetry/deduper.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export class TelemetryDeduper {
|
||||
private readonly entries = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
private readonly maxEntries: number
|
||||
) {}
|
||||
|
||||
isDuplicate(key: string, now = Date.now()) {
|
||||
const existing = this.entries.get(key);
|
||||
if (existing && existing > now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.entries.set(key, now + this.ttlMs);
|
||||
this.prune(now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private prune(now: number) {
|
||||
for (const [key, expiresAt] of this.entries) {
|
||||
if (expiresAt <= now || this.entries.size > this.maxEntries) {
|
||||
this.entries.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (this.entries.size <= this.maxEntries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
packages/backend/server/src/core/telemetry/ga4-client.ts
Normal file
124
packages/backend/server/src/core/telemetry/ga4-client.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { CleanedTelemetryEvent, Scalar } from './cleaner';
|
||||
|
||||
const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
|
||||
|
||||
type Ga4Payload = {
|
||||
client_id: string;
|
||||
user_id?: string;
|
||||
user_properties?: Record<string, { value: string }>;
|
||||
events: Array<{
|
||||
name: string;
|
||||
params: Record<string, Scalar>;
|
||||
timestamp_micros?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class Ga4Client {
|
||||
constructor(
|
||||
private readonly measurementId: string,
|
||||
private readonly apiSecret: string,
|
||||
private readonly maxEvents: number
|
||||
) {}
|
||||
|
||||
async send(events: CleanedTelemetryEvent[]) {
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.measurementId || !this.apiSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupEvents(events);
|
||||
for (const group of groups) {
|
||||
for (const chunk of chunkEvents(group.events, this.maxEvents)) {
|
||||
const payload: Ga4Payload = {
|
||||
client_id: group.clientId,
|
||||
user_id: group.userId,
|
||||
user_properties: toUserProperties(group.userProperties),
|
||||
events: chunk.map(event => ({
|
||||
name: event.eventName,
|
||||
params: event.params,
|
||||
timestamp_micros: event.timestampMicros,
|
||||
})),
|
||||
};
|
||||
|
||||
await this.post(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async post(payload: Ga4Payload) {
|
||||
const url = new URL(GA4_ENDPOINT);
|
||||
url.searchParams.set('measurement_id', this.measurementId);
|
||||
url.searchParams.set('api_secret', this.apiSecret);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`GA4 request failed with ${response.status}: ${body || 'unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GroupKey = {
|
||||
clientId: string;
|
||||
userId?: string;
|
||||
userProperties: Record<string, string>;
|
||||
events: CleanedTelemetryEvent[];
|
||||
};
|
||||
|
||||
function groupEvents(events: CleanedTelemetryEvent[]): GroupKey[] {
|
||||
const grouped = new Map<string, GroupKey>();
|
||||
|
||||
for (const event of events) {
|
||||
const key = `${event.clientId}::${event.userId ?? ''}::${serializeUserProps(event.userProperties)}`;
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
existing.events.push(event);
|
||||
} else {
|
||||
grouped.set(key, {
|
||||
clientId: event.clientId,
|
||||
userId: event.userId,
|
||||
userProperties: event.userProperties,
|
||||
events: [event],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
function serializeUserProps(props: Record<string, string>) {
|
||||
const entries = Object.entries(props).sort(([a], [b]) => a.localeCompare(b));
|
||||
return JSON.stringify(entries);
|
||||
}
|
||||
|
||||
function toUserProperties(props: Record<string, string>) {
|
||||
const userProperties: Record<string, { value: string }> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
userProperties[key] = { value };
|
||||
}
|
||||
return Object.keys(userProperties).length ? userProperties : undefined;
|
||||
}
|
||||
|
||||
function chunkEvents<T>(events: T[], size: number) {
|
||||
if (events.length <= size) {
|
||||
return [events];
|
||||
}
|
||||
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < events.length; i += size) {
|
||||
chunks.push(events.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
51
packages/backend/server/src/core/telemetry/gateway.ts
Normal file
51
packages/backend/server/src/core/telemetry/gateway.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { applyDecorators, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
SubscribeMessage as RawSubscribeMessage,
|
||||
WebSocketGateway,
|
||||
} from '@nestjs/websockets';
|
||||
import { ClsInterceptor } from 'nestjs-cls';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import { BadRequest, GatewayErrorWrapper } from '../../base';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { TelemetryService } from './service';
|
||||
import { TelemetryAck, type TelemetryBatch } from './types';
|
||||
|
||||
const SubscribeMessage = (event: string) =>
|
||||
applyDecorators(GatewayErrorWrapper(event), RawSubscribeMessage(event));
|
||||
|
||||
type EventResponse<Data = any> = [Data] extends [never]
|
||||
? { data?: never }
|
||||
: { data: Data };
|
||||
|
||||
@WebSocketGateway()
|
||||
@UseInterceptors(ClsInterceptor)
|
||||
export class TelemetryGateway {
|
||||
constructor(private readonly telemetry: TelemetryService) {}
|
||||
|
||||
@SubscribeMessage('telemetry:batch')
|
||||
async onBatch(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() batch: TelemetryBatch
|
||||
): Promise<EventResponse<TelemetryAck>> {
|
||||
const origin = client.handshake.headers.origin;
|
||||
const referer = client.handshake.headers.referer;
|
||||
if (!this.telemetry.isOriginAllowed(origin, referer)) {
|
||||
throw new BadRequest(`Invalid origin: ${origin}, referer: ${referer}`);
|
||||
}
|
||||
|
||||
const ack = await this.telemetry.collectBatch({
|
||||
...batch,
|
||||
transport: 'ws',
|
||||
events: batch?.events?.map(event => ({
|
||||
...event,
|
||||
userId: event.userId ?? user?.id,
|
||||
})),
|
||||
});
|
||||
|
||||
return { data: ack };
|
||||
}
|
||||
}
|
||||
13
packages/backend/server/src/core/telemetry/index.ts
Normal file
13
packages/backend/server/src/core/telemetry/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TelemetryController } from './controller';
|
||||
import { TelemetryGateway } from './gateway';
|
||||
import { TelemetryService } from './service';
|
||||
|
||||
@Module({
|
||||
providers: [TelemetryService, TelemetryGateway],
|
||||
controllers: [TelemetryController],
|
||||
})
|
||||
export class TelemetryModule {}
|
||||
181
packages/backend/server/src/core/telemetry/service.ts
Normal file
181
packages/backend/server/src/core/telemetry/service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, OnEvent, URLHelper } from '../../base';
|
||||
import { cleanTelemetryEvent } from './cleaner';
|
||||
import { TelemetryDeduper } from './deduper';
|
||||
import { Ga4Client } from './ga4-client';
|
||||
import { TelemetryAck, TelemetryBatch } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class TelemetryService {
|
||||
private readonly logger = new Logger(TelemetryService.name);
|
||||
private allowedOrigins: string[] = [];
|
||||
private ga4Client!: Ga4Client;
|
||||
private readonly deduper: TelemetryDeduper;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper
|
||||
) {
|
||||
this.deduper = new TelemetryDeduper(
|
||||
this.config.telemetry.dedupe.ttlHours * 60 * 60 * 1000,
|
||||
this.config.telemetry.dedupe.maxEntries
|
||||
);
|
||||
this.refreshConfig();
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
onConfigInit() {
|
||||
this.refreshConfig();
|
||||
}
|
||||
|
||||
@OnEvent('config.changed')
|
||||
onConfigChanged(event: Events['config.changed']) {
|
||||
if ('telemetry' in event.updates) {
|
||||
this.refreshConfig();
|
||||
}
|
||||
}
|
||||
|
||||
getCorsHeaders(origin?: string | string[] | null) {
|
||||
const normalized = Array.isArray(origin) ? origin[0] : origin;
|
||||
if (normalized) {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': normalized,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
isOriginAllowed(origin?: string | string[] | null, referer?: string | null) {
|
||||
const normalizedOrigin = Array.isArray(origin) ? origin[0] : origin;
|
||||
if (!normalizedOrigin && !referer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const originAllowed = normalizedOrigin
|
||||
? this.allowedOrigins.includes(normalizedOrigin)
|
||||
: false;
|
||||
if (originAllowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (referer) {
|
||||
try {
|
||||
const refererOrigin = new URL(referer).origin;
|
||||
return this.allowedOrigins.includes(refererOrigin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async collectBatch(batch: TelemetryBatch): Promise<TelemetryAck> {
|
||||
if (!batch || batch.schemaVersion !== 1 || !Array.isArray(batch.events)) {
|
||||
return {
|
||||
ok: true,
|
||||
accepted: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const events = batch.events;
|
||||
let dropped = 0;
|
||||
|
||||
const cleanedEvents = [];
|
||||
const fallbackTimestamp =
|
||||
typeof batch.sentAt === 'number' && Number.isFinite(batch.sentAt)
|
||||
? batch.sentAt * 1000
|
||||
: Date.now() * 1000;
|
||||
|
||||
for (const event of events) {
|
||||
const timestampMicros =
|
||||
typeof event.timestampMicros === 'number' &&
|
||||
Number.isFinite(event.timestampMicros)
|
||||
? event.timestampMicros
|
||||
: fallbackTimestamp;
|
||||
|
||||
const cleaned = cleanTelemetryEvent(event, {
|
||||
userId: event.userId,
|
||||
timestampMicros,
|
||||
maxParams: 25,
|
||||
});
|
||||
|
||||
if (!cleaned) {
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dedupeKey = `${cleaned.clientId}:${cleaned.eventId}`;
|
||||
if (this.deduper.isDuplicate(dedupeKey)) {
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanedEvents.push(cleaned);
|
||||
}
|
||||
|
||||
if (!cleanedEvents.length) {
|
||||
return {
|
||||
ok: true,
|
||||
accepted: 0,
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ga4Client.send(cleanedEvents);
|
||||
return {
|
||||
ok: true,
|
||||
accepted: cleanedEvents.length,
|
||||
dropped,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Telemetry forwarding failed', err);
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: err?.name ?? 'TelemetryForwardingError',
|
||||
message: err?.message ?? 'Telemetry forwarding failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private refreshConfig() {
|
||||
const normalizeOrigin = (input: string) => {
|
||||
const candidate = input.includes('://') ? input : `http://${input}`;
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (!['http:', 'https:', 'assets:'].includes(url.protocol)) {
|
||||
return null;
|
||||
} else if (url.protocol === 'assets:') {
|
||||
return url.href;
|
||||
}
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const configOrigins = this.config.telemetry.allowedOrigin
|
||||
.map(origin => normalizeOrigin(origin))
|
||||
.filter((origin): origin is string => Boolean(origin));
|
||||
|
||||
this.allowedOrigins = Array.from(
|
||||
new Set([...configOrigins, ...this.url.allowedOrigins])
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Telemetry allowed origins updated: ${this.allowedOrigins.join(', ')}`
|
||||
);
|
||||
|
||||
this.ga4Client = new Ga4Client(
|
||||
this.config.telemetry.ga4.measurementId,
|
||||
this.config.telemetry.ga4.apiSecret,
|
||||
Math.max(1, this.config.telemetry.batch.maxEvents)
|
||||
);
|
||||
}
|
||||
}
|
||||
35
packages/backend/server/src/core/telemetry/types.ts
Normal file
35
packages/backend/server/src/core/telemetry/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type TelemetryEvent = {
|
||||
schemaVersion: 1;
|
||||
eventName: string;
|
||||
params?: Record<string, unknown>;
|
||||
userProperties?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
clientId: string;
|
||||
sessionId?: string;
|
||||
eventId: string;
|
||||
timestampMicros?: number;
|
||||
context?: {
|
||||
appVersion?: string;
|
||||
editorVersion?: string;
|
||||
environment?: string;
|
||||
distribution?: string;
|
||||
channel?: 'stable' | 'beta' | 'internal' | 'canary';
|
||||
isDesktop?: boolean;
|
||||
isMobile?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
url?: string;
|
||||
referrer?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TelemetryBatch = {
|
||||
schemaVersion: 1;
|
||||
transport: 'http' | 'ws';
|
||||
sentAt: number;
|
||||
events: TelemetryEvent[];
|
||||
};
|
||||
|
||||
export type TelemetryAck =
|
||||
| { ok: true; accepted: number; dropped: number }
|
||||
| { ok: false; error: { name: string; message: string } };
|
||||
Reference in New Issue
Block a user