chore: improve event flow (#14266)

This commit is contained in:
DarkSky
2026-01-16 16:07:27 +08:00
committed by GitHub
parent d4581b839a
commit 924d58603f
43 changed files with 2306 additions and 567 deletions

View File

@@ -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

View File

@@ -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));
});

View 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;
}

View 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,
},
});

View 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);
}
}

View 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;
}
}
}
}

View 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;
}

View 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 };
}
}

View 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 {}

View 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)
);
}
}

View 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 } };