From 95104ea1c9b0f6ef052e5b32b440c7f7e140f23b Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:29:15 +0300 Subject: [PATCH 01/24] feature: telemetry adapter abstraction with TelemetryPort, NoopAdapter, and factory Introduce provider-agnostic telemetry pattern matching the existing StoragePort/StorageClientFactory architecture. UsageTelemetryService now delegates to an injected TELEMETRY_CLIENT token instead of owning HTTP logic directly. Factory reads config from NestJS ConfigService and returns NoopAdapter when telemetry is disabled or misconfigured. Closes #71 --- .../interfaces/telemetry-port.interface.ts | 11 +++ apps/api/src/config/env.schema.ts | 5 +- .../__tests__/noop-telemetry.adapter.spec.ts | 26 +++++ .../telemetry-client.factory.spec.ts | 52 ++++++++++ .../__tests__/telemetry-integration.spec.ts | 62 ++++++++++++ .../adapters/noop-telemetry.adapter.ts | 7 ++ .../src/telemetry/telemetry-client.factory.ts | 43 ++++++++ apps/api/src/telemetry/telemetry.module.ts | 26 ++++- .../src/telemetry/usage-telemetry.service.ts | 97 +++++++++++-------- 9 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/common/interfaces/telemetry-port.interface.ts create mode 100644 apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts create mode 100644 apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts create mode 100644 apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts create mode 100644 apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts create mode 100644 apps/api/src/telemetry/telemetry-client.factory.ts diff --git a/apps/api/src/common/interfaces/telemetry-port.interface.ts b/apps/api/src/common/interfaces/telemetry-port.interface.ts new file mode 100644 index 00000000..dabc4b4d --- /dev/null +++ b/apps/api/src/common/interfaces/telemetry-port.interface.ts @@ -0,0 +1,11 @@ +export interface TelemetryEvent { + distinctId: string; + event: string; + properties?: Record; +} + +export interface TelemetryPort { + capture(event: TelemetryEvent): void; + identify(distinctId: string, properties: Record): void; + shutdown(): Promise; +} diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index 5de1cadb..c79f39b9 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -49,7 +49,10 @@ export const envSchema = z.object({ LICENSE_CACHE_TTL_MS: z.coerce.number().int().min(60000).optional(), LICENSE_MAX_STALE_MS: z.coerce.number().int().min(60000).optional(), LICENSE_TIMEOUT_MS: z.coerce.number().int().min(1000).max(30000).optional(), - BETTERDB_TELEMETRY: z.string().transform(v => v !== 'false').optional(), + BETTERDB_TELEMETRY: z.string().optional(), + TELEMETRY_PROVIDER: z.enum(['http', 'posthog', 'noop']).default('posthog'), + POSTHOG_API_KEY: z.string().optional(), + POSTHOG_HOST: z.string().url().optional(), // Version check configuration VERSION_CHECK_INTERVAL_MS: z.coerce.number().int().min(60000).default(3600000), diff --git a/apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts b/apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts new file mode 100644 index 00000000..72da2b03 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts @@ -0,0 +1,26 @@ +import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; +import { NoopTelemetryAdapter } from '../adapters/noop-telemetry.adapter'; + +describe('NoopTelemetryAdapter', () => { + let adapter: TelemetryPort; + + beforeEach(() => { + adapter = new NoopTelemetryAdapter(); + }); + + it('should implement capture without side effects', () => { + expect(() => + adapter.capture({ distinctId: 'test', event: 'app_start' }), + ).not.toThrow(); + }); + + it('should implement identify without side effects', () => { + expect(() => + adapter.identify('test', { tier: 'community' }), + ).not.toThrow(); + }); + + it('should implement shutdown without side effects', async () => { + await expect(adapter.shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts new file mode 100644 index 00000000..a9c08e32 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -0,0 +1,52 @@ +import { ConfigService } from '@nestjs/config'; +import { TelemetryClientFactory } from '../telemetry-client.factory'; +import { NoopTelemetryAdapter } from '../adapters/noop-telemetry.adapter'; + +function createConfigService(env: Record = {}): ConfigService { + return { + get: jest.fn((key: string, defaultValue?: string) => env[key] ?? defaultValue), + } as unknown as ConfigService; +} + +describe('TelemetryClientFactory', () => { + it('should return NoopAdapter for TELEMETRY_PROVIDER=noop', () => { + const config = createConfigService({ TELEMETRY_PROVIDER: 'noop' }); + const factory = new TelemetryClientFactory(config); + const adapter = factory.createTelemetryClient(); + expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + }); + + it('should return NoopAdapter when BETTERDB_TELEMETRY is false regardless of provider', () => { + const config = createConfigService({ + TELEMETRY_PROVIDER: 'posthog', + BETTERDB_TELEMETRY: 'false', + POSTHOG_API_KEY: 'phc_test', + }); + const factory = new TelemetryClientFactory(config); + const adapter = factory.createTelemetryClient(); + expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + }); + + it('should fall back to NoopAdapter with warning when posthog is selected but POSTHOG_API_KEY is missing', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog' }); + const factory = new TelemetryClientFactory(config); + const adapter = factory.createTelemetryClient(); + expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('POSTHOG_API_KEY'), + ); + warnSpy.mockRestore(); + }); + + it('should default to posthog provider when TELEMETRY_PROVIDER is not set', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const config = createConfigService({}); + const factory = new TelemetryClientFactory(config); + const adapter = factory.createTelemetryClient(); + // Without POSTHOG_API_KEY, falls back to noop with warning + expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts new file mode 100644 index 00000000..af245f1e --- /dev/null +++ b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts @@ -0,0 +1,62 @@ +import { Test } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { TelemetryModule } from '../telemetry.module'; +import { UsageTelemetryService } from '../usage-telemetry.service'; +import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; + +describe('Telemetry Integration', () => { + it('should wire UsageTelemetryService to the TELEMETRY_CLIENT adapter', async () => { + const mockAdapter: TelemetryPort = { + capture: jest.fn(), + identify: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), + }; + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + TelemetryModule, + ], + }) + .overrideProvider('TELEMETRY_CLIENT') + .useValue(mockAdapter) + .compile(); + + const service = module.get(UsageTelemetryService); + + await service.trackPageView('/dashboard'); + + expect(mockAdapter.capture).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'page_view', + properties: expect.objectContaining({ path: '/dashboard' }), + }), + ); + }); + + it('should call identify on trackAppStart', async () => { + const mockAdapter: TelemetryPort = { + capture: jest.fn(), + identify: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), + }; + + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + TelemetryModule, + ], + }) + .overrideProvider('TELEMETRY_CLIENT') + .useValue(mockAdapter) + .compile(); + + const service = module.get(UsageTelemetryService); + + await service.trackAppStart(); + + expect(mockAdapter.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: 'app_start' }), + ); + }); +}); diff --git a/apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts b/apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts new file mode 100644 index 00000000..5dc5a4d3 --- /dev/null +++ b/apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts @@ -0,0 +1,7 @@ +import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; + +export class NoopTelemetryAdapter implements TelemetryPort { + capture(_event: TelemetryEvent): void {} + identify(_distinctId: string, _properties: Record): void {} + async shutdown(): Promise {} +} diff --git a/apps/api/src/telemetry/telemetry-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts new file mode 100644 index 00000000..60c0edd6 --- /dev/null +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; +import { NoopTelemetryAdapter } from './adapters/noop-telemetry.adapter'; + +@Injectable() +export class TelemetryClientFactory { + constructor(private configService: ConfigService) {} + + createTelemetryClient(): TelemetryPort { + const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY'); + if (telemetryEnabled === 'false') { + return new NoopTelemetryAdapter(); + } + + const provider = this.configService.get('TELEMETRY_PROVIDER', 'posthog'); + + switch (provider) { + case 'noop': + return new NoopTelemetryAdapter(); + + case 'posthog': { + const apiKey = this.configService.get('POSTHOG_API_KEY'); + if (!apiKey) { + console.warn( + 'TELEMETRY_PROVIDER is "posthog" but POSTHOG_API_KEY is not set. Falling back to noop telemetry.', + ); + return new NoopTelemetryAdapter(); + } + // PosthogTelemetryAdapter will be added in issue #73 + return new NoopTelemetryAdapter(); + } + + case 'http': + // HttpTelemetryAdapter will be added in issue #72 + return new NoopTelemetryAdapter(); + + default: + console.warn(`Unknown TELEMETRY_PROVIDER "${provider}". Falling back to noop telemetry.`); + return new NoopTelemetryAdapter(); + } + } +} diff --git a/apps/api/src/telemetry/telemetry.module.ts b/apps/api/src/telemetry/telemetry.module.ts index 93289ca6..5c15189e 100644 --- a/apps/api/src/telemetry/telemetry.module.ts +++ b/apps/api/src/telemetry/telemetry.module.ts @@ -1,10 +1,30 @@ -import { Module } from '@nestjs/common'; +import { Module, OnModuleDestroy, Inject } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { TelemetryController } from './telemetry.controller'; import { UsageTelemetryService } from './usage-telemetry.service'; +import { TelemetryClientFactory } from './telemetry-client.factory'; +import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; @Module({ + imports: [ConfigModule], controllers: [TelemetryController], - providers: [UsageTelemetryService], + providers: [ + TelemetryClientFactory, + { + provide: 'TELEMETRY_CLIENT', + useFactory: (factory: TelemetryClientFactory): TelemetryPort => { + return factory.createTelemetryClient(); + }, + inject: [TelemetryClientFactory], + }, + UsageTelemetryService, + ], exports: [UsageTelemetryService], }) -export class TelemetryModule {} +export class TelemetryModule implements OnModuleDestroy { + constructor(@Inject('TELEMETRY_CLIENT') private readonly telemetryClient: TelemetryPort) {} + + async onModuleDestroy(): Promise { + await this.telemetryClient.shutdown(); + } +} diff --git a/apps/api/src/telemetry/usage-telemetry.service.ts b/apps/api/src/telemetry/usage-telemetry.service.ts index a698457c..956c3e86 100644 --- a/apps/api/src/telemetry/usage-telemetry.service.ts +++ b/apps/api/src/telemetry/usage-telemetry.service.ts @@ -1,74 +1,93 @@ -import { Injectable, Optional, OnModuleInit } from '@nestjs/common'; +import { Injectable, Optional, Inject, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { LicenseService } from '@proprietary/licenses'; +import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; @Injectable() export class UsageTelemetryService implements OnModuleInit { - private telemetryUrl: string; - private workspaceName: string | null; + private instanceId: string; + private version: string; + private tier: string; + private deploymentMode: string; + private workspaceName: string | undefined; constructor( + @Inject('TELEMETRY_CLIENT') private readonly telemetryClient: TelemetryPort, + private readonly configService: ConfigService, @Optional() private readonly licenseService?: LicenseService, ) { - const entitlementUrl = process.env.ENTITLEMENT_URL || 'https://betterdb.com/api/v1/entitlements'; - const url = new URL(entitlementUrl); - url.pathname = url.pathname.replace(/\/entitlements$/, '/telemetry'); - this.telemetryUrl = url.toString(); - this.workspaceName = process.env.TENANT_ID || null; + this.version = + this.configService.get('APP_VERSION') || + this.configService.get('npm_package_version') || + 'unknown'; + this.deploymentMode = + this.configService.get('CLOUD_MODE') === 'true' ? 'cloud' : 'self-hosted'; + this.workspaceName = this.configService.get('TENANT_ID') || undefined; + this.instanceId = ''; + this.tier = 'community'; } async onModuleInit(): Promise { if (!this.licenseService) return; await this.licenseService.validationPromise; + + this.instanceId = this.licenseService.getInstanceId(); + this.tier = this.licenseService.getLicenseTier(); + + this.telemetryClient.identify(this.instanceId, { + version: this.version, + tier: this.tier, + deploymentMode: this.deploymentMode, + }); + await this.trackAppStart(); } - private async sendEvent(eventType: string, payload?: Record): Promise { - try { - if (!this.licenseService?.isTelemetryEnabled) return; - - const body = { - instanceId: this.licenseService.getInstanceId(), - eventType, - version: process.env.APP_VERSION || process.env.npm_package_version || 'unknown', - tier: this.licenseService.getLicenseTier(), - deploymentMode: process.env.CLOUD_MODE === 'true' ? 'cloud' : 'self-hosted', - workspaceName: this.workspaceName || undefined, + private sendEvent(eventType: string, payload?: Record): void { + this.telemetryClient.capture({ + distinctId: this.instanceId, + event: eventType, + properties: { + version: this.version, + tier: this.tier, + deploymentMode: this.deploymentMode, + workspaceName: this.workspaceName, timestamp: Date.now(), - payload, - }; - - await fetch(this.telemetryUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5000), - }); - } catch { - // fire-and-forget - } + ...payload, + }, + }); } async trackAppStart(): Promise { - await this.sendEvent('app_start'); + this.sendEvent('app_start'); } async trackInteractionAfterIdle(idleDurationMs: number): Promise { - await this.sendEvent('interaction_after_idle', { idleDurationMs }); + this.sendEvent('interaction_after_idle', { idleDurationMs }); } - async trackDbConnect(opts: { connectionType: string; success: boolean; isFirstConnection: boolean }): Promise { - await this.sendEvent('db_connect', opts); + async trackDbConnect(opts: { + connectionType: string; + success: boolean; + isFirstConnection: boolean; + }): Promise { + this.sendEvent('db_connect', opts); } async trackDbSwitch(totalConnections: number, dbType: string, dbVersion: string): Promise { - await this.sendEvent('db_switch', { totalConnections, dbType, dbVersion }); + this.sendEvent('db_switch', { totalConnections, dbType, dbVersion }); } async trackPageView(path: string): Promise { - await this.sendEvent('page_view', { path }); + this.sendEvent('page_view', { path }); } - async trackMcpToolCall(event: { toolName: string; success: boolean; durationMs: number; error?: string }): Promise { - await this.sendEvent('mcp_tool_call', event); + async trackMcpToolCall(event: { + toolName: string; + success: boolean; + durationMs: number; + error?: string; + }): Promise { + this.sendEvent('mcp_tool_call', event); } } From e1364a4a807a00fa89db20067210e345d0b8d5af Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:35:48 +0300 Subject: [PATCH 02/24] fix: restore BETTERDB_TELEMETRY transform, use z.url() for POSTHOG_HOST, remove any --- apps/api/src/config/env.schema.ts | 190 +++++++++++------- .../telemetry-client.factory.spec.ts | 10 +- .../src/telemetry/telemetry-client.factory.ts | 4 +- 3 files changed, 121 insertions(+), 83 deletions(-) diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index c79f39b9..635a92b1 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -4,90 +4,124 @@ import { z } from 'zod'; * Environment variable validation schema * Validates all environment variables at application startup */ -export const envSchema = z.object({ - // Application - PORT: z.coerce.number().int().min(1).max(65535).default(3001), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - - // Database (Valkey/Redis connection) - DB_HOST: z.string().min(1).default('localhost'), - DB_PORT: z.coerce.number().int().min(1).max(65535).default(6379), - DB_USERNAME: z.string().default('default'), - DB_PASSWORD: z.string().default(''), - DB_TYPE: z.enum(['valkey', 'redis', 'auto']).default('auto'), - - // Storage configuration - STORAGE_TYPE: z.enum(['sqlite', 'postgres', 'postgresql', 'memory']).default('sqlite'), - STORAGE_URL: z.string().url().optional(), - STORAGE_SQLITE_FILEPATH: z.string().default('./data/audit.db'), - DB_SCHEMA: z.string().regex(/^[a-z_][a-z0-9_]*$/).max(63).optional(), - - // CLI static directory override - BETTERDB_STATIC_DIR: z.string().optional(), - - // Polling intervals - AUDIT_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(60000), - CLIENT_ANALYTICS_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(60000), - - // AI configuration - AI_ENABLED: z.string().default('false').transform(v => v === 'true'), - OLLAMA_BASE_URL: z.string().url().default('http://localhost:11434'), - OLLAMA_KEEP_ALIVE: z.string().default('24h'), - AI_USE_LLM_CLASSIFICATION: z.string().default('false').transform(v => v === 'true'), - LANCEDB_PATH: z.string().default('./data/lancedb'), - VALKEY_DOCS_PATH: z.string().default('./data/valkey-docs'), - - // Anomaly detection - ANOMALY_DETECTION_ENABLED: z.string().default('true').transform(v => v !== 'false'), - ANOMALY_POLL_INTERVAL_MS: z.coerce.number().int().min(100).default(1000), - ANOMALY_CACHE_TTL_MS: z.coerce.number().int().min(1000).default(3600000), - ANOMALY_PROMETHEUS_INTERVAL_MS: z.coerce.number().int().min(1000).default(30000), - - // License configuration (optional) - BETTERDB_LICENSE_KEY: z.string().optional(), - ENTITLEMENT_URL: z.string().url().optional(), - LICENSE_CACHE_TTL_MS: z.coerce.number().int().min(60000).optional(), - LICENSE_MAX_STALE_MS: z.coerce.number().int().min(60000).optional(), - LICENSE_TIMEOUT_MS: z.coerce.number().int().min(1000).max(30000).optional(), - BETTERDB_TELEMETRY: z.string().optional(), - TELEMETRY_PROVIDER: z.enum(['http', 'posthog', 'noop']).default('posthog'), - POSTHOG_API_KEY: z.string().optional(), - POSTHOG_HOST: z.string().url().optional(), - - // Version check configuration - VERSION_CHECK_INTERVAL_MS: z.coerce.number().int().min(60000).default(3600000), - - // Webhook configuration - WEBHOOK_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).optional(), - WEBHOOK_MAX_RESPONSE_BODY_BYTES: z.coerce.number().int().min(0).optional(), - - // Security - ENCRYPTION_KEY: z.string().min(16).optional(), -}).superRefine((data, ctx) => { - // Require STORAGE_URL when using postgres - if ((data.STORAGE_TYPE === 'postgres' || data.STORAGE_TYPE === 'postgresql') && !data.STORAGE_URL) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'STORAGE_URL is required when STORAGE_TYPE is postgres or postgresql', - path: ['STORAGE_URL'], - }); - } - - // Validate STORAGE_URL is a valid postgres URL when provided - if (data.STORAGE_URL && (data.STORAGE_TYPE === 'postgres' || data.STORAGE_TYPE === 'postgresql')) { - if (!data.STORAGE_URL.startsWith('postgres://') && !data.STORAGE_URL.startsWith('postgresql://')) { +export const envSchema = z + .object({ + // Application + PORT: z.coerce.number().int().min(1).max(65535).default(3001), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + + // Database (Valkey/Redis connection) + DB_HOST: z.string().min(1).default('localhost'), + DB_PORT: z.coerce.number().int().min(1).max(65535).default(6379), + DB_USERNAME: z.string().default('default'), + DB_PASSWORD: z.string().default(''), + DB_TYPE: z.enum(['valkey', 'redis', 'auto']).default('auto'), + + // Storage configuration + STORAGE_TYPE: z.enum(['sqlite', 'postgres', 'postgresql', 'memory']).default('sqlite'), + STORAGE_URL: z.string().url().optional(), + STORAGE_SQLITE_FILEPATH: z.string().default('./data/audit.db'), + DB_SCHEMA: z + .string() + .regex(/^[a-z_][a-z0-9_]*$/) + .max(63) + .optional(), + + // CLI static directory override + BETTERDB_STATIC_DIR: z.string().optional(), + + // Polling intervals + AUDIT_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(60000), + CLIENT_ANALYTICS_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(60000), + + // AI configuration + AI_ENABLED: z + .string() + .default('false') + .transform((v) => v === 'true'), + OLLAMA_BASE_URL: z.string().url().default('http://localhost:11434'), + OLLAMA_KEEP_ALIVE: z.string().default('24h'), + AI_USE_LLM_CLASSIFICATION: z + .string() + .default('false') + .transform((v) => v === 'true'), + LANCEDB_PATH: z.string().default('./data/lancedb'), + VALKEY_DOCS_PATH: z.string().default('./data/valkey-docs'), + + // Anomaly detection + ANOMALY_DETECTION_ENABLED: z + .string() + .default('true') + .transform((v) => v !== 'false'), + ANOMALY_POLL_INTERVAL_MS: z.coerce.number().int().min(100).default(1000), + ANOMALY_CACHE_TTL_MS: z.coerce.number().int().min(1000).default(3600000), + ANOMALY_PROMETHEUS_INTERVAL_MS: z.coerce.number().int().min(1000).default(30000), + + // License configuration (optional) + BETTERDB_LICENSE_KEY: z.string().optional(), + ENTITLEMENT_URL: z.string().url().optional(), + LICENSE_CACHE_TTL_MS: z.coerce.number().int().min(60000).optional(), + LICENSE_MAX_STALE_MS: z.coerce.number().int().min(60000).optional(), + LICENSE_TIMEOUT_MS: z.coerce.number().int().min(1000).max(30000).optional(), + BETTERDB_TELEMETRY: z + .string() + .transform((v) => v !== 'false') + .optional(), + TELEMETRY_PROVIDER: z.enum(['http', 'posthog', 'noop']).default('posthog'), + POSTHOG_API_KEY: z.string().optional(), + POSTHOG_HOST: z.url().optional(), + + // Version check configuration + VERSION_CHECK_INTERVAL_MS: z.coerce.number().int().min(60000).default(3600000), + + // Webhook configuration + WEBHOOK_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).optional(), + WEBHOOK_MAX_RESPONSE_BODY_BYTES: z.coerce.number().int().min(0).optional(), + + // Security + ENCRYPTION_KEY: z.string().min(16).optional(), + }) + .superRefine((data, ctx) => { + // Require STORAGE_URL when using postgres + if ( + (data.STORAGE_TYPE === 'postgres' || data.STORAGE_TYPE === 'postgresql') && + !data.STORAGE_URL + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'STORAGE_URL must be a valid PostgreSQL connection string (postgres:// or postgresql://)', + message: 'STORAGE_URL is required when STORAGE_TYPE is postgres or postgresql', path: ['STORAGE_URL'], }); } - } - if (data.AI_ENABLED && data.OLLAMA_BASE_URL === 'http://localhost:11434' && data.NODE_ENV === 'production') { - console.warn('Warning: AI is enabled in production with default Ollama URL (localhost:11434)'); - } -}); + // Validate STORAGE_URL is a valid postgres URL when provided + if ( + data.STORAGE_URL && + (data.STORAGE_TYPE === 'postgres' || data.STORAGE_TYPE === 'postgresql') + ) { + if ( + !data.STORAGE_URL.startsWith('postgres://') && + !data.STORAGE_URL.startsWith('postgresql://') + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'STORAGE_URL must be a valid PostgreSQL connection string (postgres:// or postgresql://)', + path: ['STORAGE_URL'], + }); + } + } + + if ( + data.AI_ENABLED && + data.OLLAMA_BASE_URL === 'http://localhost:11434' && + data.NODE_ENV === 'production' + ) { + console.warn( + 'Warning: AI is enabled in production with default Ollama URL (localhost:11434)', + ); + } + }); export type EnvConfig = z.infer; diff --git a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts index a9c08e32..1b79b80c 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -2,9 +2,13 @@ import { ConfigService } from '@nestjs/config'; import { TelemetryClientFactory } from '../telemetry-client.factory'; import { NoopTelemetryAdapter } from '../adapters/noop-telemetry.adapter'; -function createConfigService(env: Record = {}): ConfigService { +function createConfigService( + env: Record = {}, +): ConfigService { return { - get: jest.fn((key: string, defaultValue?: string) => env[key] ?? defaultValue), + get: jest.fn( + (key: string, defaultValue?: string | boolean) => env[key] ?? defaultValue, + ), } as unknown as ConfigService; } @@ -19,7 +23,7 @@ describe('TelemetryClientFactory', () => { it('should return NoopAdapter when BETTERDB_TELEMETRY is false regardless of provider', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog', - BETTERDB_TELEMETRY: 'false', + BETTERDB_TELEMETRY: false, POSTHOG_API_KEY: 'phc_test', }); const factory = new TelemetryClientFactory(config); diff --git a/apps/api/src/telemetry/telemetry-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts index 60c0edd6..12094f06 100644 --- a/apps/api/src/telemetry/telemetry-client.factory.ts +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -8,8 +8,8 @@ export class TelemetryClientFactory { constructor(private configService: ConfigService) {} createTelemetryClient(): TelemetryPort { - const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY'); - if (telemetryEnabled === 'false') { + const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY'); + if (telemetryEnabled === false) { return new NoopTelemetryAdapter(); } From 685fcf8f180eb02e4df99994c78dcfc043876fa2 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:42:46 +0300 Subject: [PATCH 03/24] chore: rename NoopTelemetryAdapter to NoopTelemetryClientAdapter for consistency --- ...ec.ts => noop-telemetry-client.adapter.spec.ts} | 6 +++--- .../__tests__/telemetry-client.factory.spec.ts | 10 +++++----- ...adapter.ts => noop-telemetry-client.adapter.ts} | 2 +- apps/api/src/telemetry/telemetry-client.factory.ts | 14 +++++++------- 4 files changed, 16 insertions(+), 16 deletions(-) rename apps/api/src/telemetry/__tests__/{noop-telemetry.adapter.spec.ts => noop-telemetry-client.adapter.spec.ts} (77%) rename apps/api/src/telemetry/adapters/{noop-telemetry.adapter.ts => noop-telemetry-client.adapter.ts} (79%) diff --git a/apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts b/apps/api/src/telemetry/__tests__/noop-telemetry-client.adapter.spec.ts similarity index 77% rename from apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts rename to apps/api/src/telemetry/__tests__/noop-telemetry-client.adapter.spec.ts index 72da2b03..b9783271 100644 --- a/apps/api/src/telemetry/__tests__/noop-telemetry.adapter.spec.ts +++ b/apps/api/src/telemetry/__tests__/noop-telemetry-client.adapter.spec.ts @@ -1,11 +1,11 @@ import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; -import { NoopTelemetryAdapter } from '../adapters/noop-telemetry.adapter'; +import { NoopTelemetryClientAdapter } from '../adapters/noop-telemetry-client.adapter'; -describe('NoopTelemetryAdapter', () => { +describe('NoopTelemetryClientAdapter', () => { let adapter: TelemetryPort; beforeEach(() => { - adapter = new NoopTelemetryAdapter(); + adapter = new NoopTelemetryClientAdapter(); }); it('should implement capture without side effects', () => { diff --git a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts index 1b79b80c..31524fc6 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -1,6 +1,6 @@ import { ConfigService } from '@nestjs/config'; import { TelemetryClientFactory } from '../telemetry-client.factory'; -import { NoopTelemetryAdapter } from '../adapters/noop-telemetry.adapter'; +import { NoopTelemetryClientAdapter } from '../adapters/noop-telemetry-client.adapter'; function createConfigService( env: Record = {}, @@ -17,7 +17,7 @@ describe('TelemetryClientFactory', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'noop' }); const factory = new TelemetryClientFactory(config); const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); }); it('should return NoopAdapter when BETTERDB_TELEMETRY is false regardless of provider', () => { @@ -28,7 +28,7 @@ describe('TelemetryClientFactory', () => { }); const factory = new TelemetryClientFactory(config); const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); }); it('should fall back to NoopAdapter with warning when posthog is selected but POSTHOG_API_KEY is missing', () => { @@ -36,7 +36,7 @@ describe('TelemetryClientFactory', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog' }); const factory = new TelemetryClientFactory(config); const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('POSTHOG_API_KEY'), ); @@ -49,7 +49,7 @@ describe('TelemetryClientFactory', () => { const factory = new TelemetryClientFactory(config); const adapter = factory.createTelemetryClient(); // Without POSTHOG_API_KEY, falls back to noop with warning - expect(adapter).toBeInstanceOf(NoopTelemetryAdapter); + expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); expect(warnSpy).toHaveBeenCalled(); warnSpy.mockRestore(); }); diff --git a/apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts b/apps/api/src/telemetry/adapters/noop-telemetry-client.adapter.ts similarity index 79% rename from apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts rename to apps/api/src/telemetry/adapters/noop-telemetry-client.adapter.ts index 5dc5a4d3..797ecaa9 100644 --- a/apps/api/src/telemetry/adapters/noop-telemetry.adapter.ts +++ b/apps/api/src/telemetry/adapters/noop-telemetry-client.adapter.ts @@ -1,6 +1,6 @@ import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; -export class NoopTelemetryAdapter implements TelemetryPort { +export class NoopTelemetryClientAdapter implements TelemetryPort { capture(_event: TelemetryEvent): void {} identify(_distinctId: string, _properties: Record): void {} async shutdown(): Promise {} diff --git a/apps/api/src/telemetry/telemetry-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts index 12094f06..d15424c5 100644 --- a/apps/api/src/telemetry/telemetry-client.factory.ts +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; -import { NoopTelemetryAdapter } from './adapters/noop-telemetry.adapter'; +import { NoopTelemetryClientAdapter } from './adapters/noop-telemetry-client.adapter'; @Injectable() export class TelemetryClientFactory { @@ -10,14 +10,14 @@ export class TelemetryClientFactory { createTelemetryClient(): TelemetryPort { const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY'); if (telemetryEnabled === false) { - return new NoopTelemetryAdapter(); + return new NoopTelemetryClientAdapter(); } const provider = this.configService.get('TELEMETRY_PROVIDER', 'posthog'); switch (provider) { case 'noop': - return new NoopTelemetryAdapter(); + return new NoopTelemetryClientAdapter(); case 'posthog': { const apiKey = this.configService.get('POSTHOG_API_KEY'); @@ -25,19 +25,19 @@ export class TelemetryClientFactory { console.warn( 'TELEMETRY_PROVIDER is "posthog" but POSTHOG_API_KEY is not set. Falling back to noop telemetry.', ); - return new NoopTelemetryAdapter(); + return new NoopTelemetryClientAdapter(); } // PosthogTelemetryAdapter will be added in issue #73 - return new NoopTelemetryAdapter(); + return new NoopTelemetryClientAdapter(); } case 'http': // HttpTelemetryAdapter will be added in issue #72 - return new NoopTelemetryAdapter(); + return new NoopTelemetryClientAdapter(); default: console.warn(`Unknown TELEMETRY_PROVIDER "${provider}". Falling back to noop telemetry.`); - return new NoopTelemetryAdapter(); + return new NoopTelemetryClientAdapter(); } } } From 52d7eb2530da4a10e73baff4ed3703335d918ad6 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:43:40 +0300 Subject: [PATCH 04/24] fix: clarify factory test name to match actual assertion --- .../telemetry/__tests__/telemetry-client.factory.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts index 31524fc6..a12999ac 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -43,14 +43,15 @@ describe('TelemetryClientFactory', () => { warnSpy.mockRestore(); }); - it('should default to posthog provider when TELEMETRY_PROVIDER is not set', () => { + it('should fall back to noop with warning when no env vars are set (default is posthog, but key is missing)', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); const config = createConfigService({}); const factory = new TelemetryClientFactory(config); const adapter = factory.createTelemetryClient(); - // Without POSTHOG_API_KEY, falls back to noop with warning expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); - expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('POSTHOG_API_KEY'), + ); warnSpy.mockRestore(); }); }); From 4f95f850b62e3bc799c1f0eb108b835099f13ddf Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:46:53 +0300 Subject: [PATCH 05/24] feature: add stub Http and Posthog adapters, wire factory to return correct types Add HttpTelemetryClientAdapter and PosthogTelemetryClientAdapter stubs implementing TelemetryPort. Factory now returns the correct adapter type per provider config. Real implementations in #72 and #73. --- .../telemetry-client.factory.spec.ts | 41 ++++++++++++------- .../adapters/http-telemetry-client.adapter.ts | 10 +++++ .../posthog-telemetry-client.adapter.ts | 13 ++++++ .../src/telemetry/telemetry-client.factory.ts | 17 +++++--- 4 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/telemetry/adapters/http-telemetry-client.adapter.ts create mode 100644 apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts diff --git a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts index a12999ac..1458b50b 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -1,6 +1,8 @@ import { ConfigService } from '@nestjs/config'; import { TelemetryClientFactory } from '../telemetry-client.factory'; import { NoopTelemetryClientAdapter } from '../adapters/noop-telemetry-client.adapter'; +import { HttpTelemetryClientAdapter } from '../adapters/http-telemetry-client.adapter'; +import { PosthogTelemetryClientAdapter } from '../adapters/posthog-telemetry-client.adapter'; function createConfigService( env: Record = {}, @@ -13,44 +15,55 @@ function createConfigService( } describe('TelemetryClientFactory', () => { - it('should return NoopAdapter for TELEMETRY_PROVIDER=noop', () => { + it('should return NoopTelemetryClientAdapter for TELEMETRY_PROVIDER=noop', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'noop' }); const factory = new TelemetryClientFactory(config); - const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); }); - it('should return NoopAdapter when BETTERDB_TELEMETRY is false regardless of provider', () => { + it('should return HttpTelemetryClientAdapter for TELEMETRY_PROVIDER=http', () => { + const config = createConfigService({ TELEMETRY_PROVIDER: 'http' }); + const factory = new TelemetryClientFactory(config); + expect(factory.createTelemetryClient()).toBeInstanceOf(HttpTelemetryClientAdapter); + }); + + it('should return PosthogTelemetryClientAdapter for TELEMETRY_PROVIDER=posthog with API key', () => { + const config = createConfigService({ + TELEMETRY_PROVIDER: 'posthog', + POSTHOG_API_KEY: 'phc_test', + }); + const factory = new TelemetryClientFactory(config); + expect(factory.createTelemetryClient()).toBeInstanceOf(PosthogTelemetryClientAdapter); + }); + + it('should return NoopTelemetryClientAdapter when BETTERDB_TELEMETRY is false regardless of provider', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog', BETTERDB_TELEMETRY: false, POSTHOG_API_KEY: 'phc_test', }); const factory = new TelemetryClientFactory(config); - const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); }); - it('should fall back to NoopAdapter with warning when posthog is selected but POSTHOG_API_KEY is missing', () => { + it('should fall back to NoopTelemetryClientAdapter with warning when posthog key is missing', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog' }); const factory = new TelemetryClientFactory(config); - const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('POSTHOG_API_KEY'), ); warnSpy.mockRestore(); }); - it('should fall back to noop with warning when no env vars are set (default is posthog, but key is missing)', () => { + it('should fall back to NoopTelemetryClientAdapter with warning for unknown provider', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const config = createConfigService({}); + const config = createConfigService({ TELEMETRY_PROVIDER: 'datadog' }); const factory = new TelemetryClientFactory(config); - const adapter = factory.createTelemetryClient(); - expect(adapter).toBeInstanceOf(NoopTelemetryClientAdapter); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('POSTHOG_API_KEY'), + expect.stringContaining('Unknown TELEMETRY_PROVIDER'), ); warnSpy.mockRestore(); }); diff --git a/apps/api/src/telemetry/adapters/http-telemetry-client.adapter.ts b/apps/api/src/telemetry/adapters/http-telemetry-client.adapter.ts new file mode 100644 index 00000000..be1118c2 --- /dev/null +++ b/apps/api/src/telemetry/adapters/http-telemetry-client.adapter.ts @@ -0,0 +1,10 @@ +import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; + +// TODO(#72): Replace stubs with real HTTP fetch implementation +export class HttpTelemetryClientAdapter implements TelemetryPort { + constructor(private readonly telemetryUrl: string) {} + + capture(_event: TelemetryEvent): void {} + identify(_distinctId: string, _properties: Record): void {} + async shutdown(): Promise {} +} diff --git a/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts b/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts new file mode 100644 index 00000000..0b37286e --- /dev/null +++ b/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts @@ -0,0 +1,13 @@ +import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; + +// TODO(#73): Replace stubs with real posthog-node implementation +export class PosthogTelemetryClientAdapter implements TelemetryPort { + constructor( + private readonly apiKey: string, + private readonly host?: string, + ) {} + + capture(_event: TelemetryEvent): void {} + identify(_distinctId: string, _properties: Record): void {} + async shutdown(): Promise {} +} diff --git a/apps/api/src/telemetry/telemetry-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts index d15424c5..0d471108 100644 --- a/apps/api/src/telemetry/telemetry-client.factory.ts +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; import { NoopTelemetryClientAdapter } from './adapters/noop-telemetry-client.adapter'; +import { HttpTelemetryClientAdapter } from './adapters/http-telemetry-client.adapter'; +import { PosthogTelemetryClientAdapter } from './adapters/posthog-telemetry-client.adapter'; @Injectable() export class TelemetryClientFactory { @@ -27,13 +29,18 @@ export class TelemetryClientFactory { ); return new NoopTelemetryClientAdapter(); } - // PosthogTelemetryAdapter will be added in issue #73 - return new NoopTelemetryClientAdapter(); + const host = this.configService.get('POSTHOG_HOST'); + return new PosthogTelemetryClientAdapter(apiKey, host); } - case 'http': - // HttpTelemetryAdapter will be added in issue #72 - return new NoopTelemetryClientAdapter(); + case 'http': { + const entitlementUrl = + this.configService.get('ENTITLEMENT_URL') || + 'https://betterdb.com/api/v1/entitlements'; + const url = new URL(entitlementUrl); + url.pathname = url.pathname.replace(/\/entitlements$/, '/telemetry'); + return new HttpTelemetryClientAdapter(url.toString()); + } default: console.warn(`Unknown TELEMETRY_PROVIDER "${provider}". Falling back to noop telemetry.`); From a95b12be684ec9afef026c900173d92048db1638 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:51:20 +0300 Subject: [PATCH 06/24] chore: extract shared setup into beforeEach in integration tests --- .../__tests__/telemetry-integration.spec.ts | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts index af245f1e..2bd1d401 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts @@ -1,18 +1,28 @@ -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { TelemetryModule } from '../telemetry.module'; import { UsageTelemetryService } from '../usage-telemetry.service'; import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; +function createMockAdapter(): TelemetryPort & { + capture: jest.Mock; + identify: jest.Mock; + shutdown: jest.Mock; +} { + return { + capture: jest.fn(), + identify: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), + }; +} + describe('Telemetry Integration', () => { - it('should wire UsageTelemetryService to the TELEMETRY_CLIENT adapter', async () => { - const mockAdapter: TelemetryPort = { - capture: jest.fn(), - identify: jest.fn(), - shutdown: jest.fn().mockResolvedValue(undefined), - }; - - const module = await Test.createTestingModule({ + let mockAdapter: ReturnType; + let service: UsageTelemetryService; + + beforeEach(async () => { + mockAdapter = createMockAdapter(); + const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), TelemetryModule, @@ -22,8 +32,10 @@ describe('Telemetry Integration', () => { .useValue(mockAdapter) .compile(); - const service = module.get(UsageTelemetryService); + service = module.get(UsageTelemetryService); + }); + it('should delegate trackPageView to the TELEMETRY_CLIENT adapter', async () => { await service.trackPageView('/dashboard'); expect(mockAdapter.capture).toHaveBeenCalledWith( @@ -34,25 +46,7 @@ describe('Telemetry Integration', () => { ); }); - it('should call identify on trackAppStart', async () => { - const mockAdapter: TelemetryPort = { - capture: jest.fn(), - identify: jest.fn(), - shutdown: jest.fn().mockResolvedValue(undefined), - }; - - const module = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), - TelemetryModule, - ], - }) - .overrideProvider('TELEMETRY_CLIENT') - .useValue(mockAdapter) - .compile(); - - const service = module.get(UsageTelemetryService); - + it('should delegate trackAppStart to the TELEMETRY_CLIENT adapter', async () => { await service.trackAppStart(); expect(mockAdapter.capture).toHaveBeenCalledWith( From 2a772eb0e71beeda7d1f82d3841fc59a35823e0f Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 09:52:20 +0300 Subject: [PATCH 07/24] fix: add missing await to ConfigModule initialization in telemetry test --- apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts index 2bd1d401..06d190c7 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts @@ -24,7 +24,7 @@ describe('Telemetry Integration', () => { mockAdapter = createMockAdapter(); const module: TestingModule = await Test.createTestingModule({ imports: [ - ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + await ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), TelemetryModule, ], }) From d85f42dd8115cbcf06c2263e6fec6456032f5c11 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 10:04:58 +0300 Subject: [PATCH 08/24] fix: address code review findings from PR bot - Guard sendEvent against empty instanceId (no licenseService case) - Fix payload spread order: base properties override caller payload - Use NestJS Logger instead of console.warn in factory and module - Add PostHog stub warning when API key is set but adapter not implemented - Catch shutdown errors in onModuleDestroy (non-fatal teardown) - Handle both boolean and string 'false' for BETTERDB_TELEMETRY - Warn and fall back to noop when ENTITLEMENT_URL path is invalid - Add factory tests for http, posthog, unknown provider, and URL fallback - Restructure integration tests: guard behavior + identity lifecycle --- .../telemetry-client.factory.spec.ts | 43 +++++++++++--- .../__tests__/telemetry-integration.spec.ts | 58 +++++++++++++++---- .../src/telemetry/telemetry-client.factory.ts | 28 +++++++-- apps/api/src/telemetry/telemetry.module.ts | 8 ++- .../src/telemetry/usage-telemetry.service.ts | 3 +- 5 files changed, 113 insertions(+), 27 deletions(-) diff --git a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts index 1458b50b..dc17d044 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TelemetryClientFactory } from '../telemetry-client.factory'; import { NoopTelemetryClientAdapter } from '../adapters/noop-telemetry-client.adapter'; @@ -15,6 +16,16 @@ function createConfigService( } describe('TelemetryClientFactory', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + it('should return NoopTelemetryClientAdapter for TELEMETRY_PROVIDER=noop', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'noop' }); const factory = new TelemetryClientFactory(config); @@ -36,7 +47,7 @@ describe('TelemetryClientFactory', () => { expect(factory.createTelemetryClient()).toBeInstanceOf(PosthogTelemetryClientAdapter); }); - it('should return NoopTelemetryClientAdapter when BETTERDB_TELEMETRY is false regardless of provider', () => { + it('should return NoopTelemetryClientAdapter when BETTERDB_TELEMETRY is boolean false', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog', BETTERDB_TELEMETRY: false, @@ -46,25 +57,43 @@ describe('TelemetryClientFactory', () => { expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); }); - it('should fall back to NoopTelemetryClientAdapter with warning when posthog key is missing', () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + it('should return NoopTelemetryClientAdapter when BETTERDB_TELEMETRY is string "false"', () => { + const config = createConfigService({ + TELEMETRY_PROVIDER: 'posthog', + BETTERDB_TELEMETRY: 'false', + POSTHOG_API_KEY: 'phc_test', + }); + const factory = new TelemetryClientFactory(config); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); + }); + + it('should fall back to NoopTelemetryClientAdapter and warn when posthog key is missing', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'posthog' }); const factory = new TelemetryClientFactory(config); expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('POSTHOG_API_KEY'), ); - warnSpy.mockRestore(); }); - it('should fall back to NoopTelemetryClientAdapter with warning for unknown provider', () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + it('should fall back to NoopTelemetryClientAdapter and warn for unknown provider', () => { const config = createConfigService({ TELEMETRY_PROVIDER: 'datadog' }); const factory = new TelemetryClientFactory(config); expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Unknown TELEMETRY_PROVIDER'), ); - warnSpy.mockRestore(); + }); + + it('should fall back to NoopTelemetryClientAdapter when ENTITLEMENT_URL path is invalid for http', () => { + const config = createConfigService({ + TELEMETRY_PROVIDER: 'http', + ENTITLEMENT_URL: 'https://example.com/api/v1/other', + }); + const factory = new TelemetryClientFactory(config); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('does not end with "/entitlements"'), + ); }); }); diff --git a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts index 06d190c7..97436b71 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { TelemetryModule } from '../telemetry.module'; import { UsageTelemetryService } from '../usage-telemetry.service'; import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; +import { ConfigService } from '@nestjs/config'; function createMockAdapter(): TelemetryPort & { capture: jest.Mock; @@ -18,10 +19,12 @@ function createMockAdapter(): TelemetryPort & { describe('Telemetry Integration', () => { let mockAdapter: ReturnType; - let service: UsageTelemetryService; - beforeEach(async () => { + beforeEach(() => { mockAdapter = createMockAdapter(); + }); + + it('should not capture events when instanceId is not set (no licenseService)', async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ await ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), @@ -32,25 +35,58 @@ describe('Telemetry Integration', () => { .useValue(mockAdapter) .compile(); - service = module.get(UsageTelemetryService); + const service = module.get(UsageTelemetryService); + + await service.trackPageView('/dashboard'); + await service.trackAppStart(); + + expect(mockAdapter.capture).not.toHaveBeenCalled(); }); - it('should delegate trackPageView to the TELEMETRY_CLIENT adapter', async () => { + it('should delegate events to adapter when instanceId is set', async () => { + const mockLicenseService = { + validationPromise: Promise.resolve(), + getInstanceId: jest.fn().mockReturnValue('test-instance-id'), + getLicenseTier: jest.fn().mockReturnValue('community'), + isTelemetryEnabled: true, + }; + + const module: TestingModule = await Test.createTestingModule({ + imports: [await ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true })], + providers: [ + { provide: 'TELEMETRY_CLIENT', useValue: mockAdapter }, + { + provide: UsageTelemetryService, + useFactory: (configService: ConfigService) => + new UsageTelemetryService(mockAdapter, configService, mockLicenseService as never), + inject: [ConfigService], + }, + ], + }).compile(); + + await module.init(); + + const service = module.get(UsageTelemetryService); + + expect(mockAdapter.identify).toHaveBeenCalledWith( + 'test-instance-id', + expect.objectContaining({ tier: 'community' }), + ); + expect(mockAdapter.capture).toHaveBeenCalledWith( + expect.objectContaining({ event: 'app_start', distinctId: 'test-instance-id' }), + ); + + mockAdapter.capture.mockClear(); await service.trackPageView('/dashboard'); expect(mockAdapter.capture).toHaveBeenCalledWith( expect.objectContaining({ event: 'page_view', + distinctId: 'test-instance-id', properties: expect.objectContaining({ path: '/dashboard' }), }), ); - }); - - it('should delegate trackAppStart to the TELEMETRY_CLIENT adapter', async () => { - await service.trackAppStart(); - expect(mockAdapter.capture).toHaveBeenCalledWith( - expect.objectContaining({ event: 'app_start' }), - ); + await module.close(); }); }); diff --git a/apps/api/src/telemetry/telemetry-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts index 0d471108..dd67ee4b 100644 --- a/apps/api/src/telemetry/telemetry-client.factory.ts +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; import { NoopTelemetryClientAdapter } from './adapters/noop-telemetry-client.adapter'; @@ -7,11 +7,13 @@ import { PosthogTelemetryClientAdapter } from './adapters/posthog-telemetry-clie @Injectable() export class TelemetryClientFactory { + private readonly logger = new Logger(TelemetryClientFactory.name); + constructor(private configService: ConfigService) {} createTelemetryClient(): TelemetryPort { - const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY'); - if (telemetryEnabled === false) { + const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY'); + if (telemetryEnabled === false || telemetryEnabled === 'false') { return new NoopTelemetryClientAdapter(); } @@ -24,12 +26,16 @@ export class TelemetryClientFactory { case 'posthog': { const apiKey = this.configService.get('POSTHOG_API_KEY'); if (!apiKey) { - console.warn( + this.logger.warn( 'TELEMETRY_PROVIDER is "posthog" but POSTHOG_API_KEY is not set. Falling back to noop telemetry.', ); return new NoopTelemetryClientAdapter(); } const host = this.configService.get('POSTHOG_HOST'); + // TODO(#73): Replace stub with real posthog-node implementation + this.logger.warn( + 'PostHog adapter is not yet implemented (#73). Events will be discarded.', + ); return new PosthogTelemetryClientAdapter(apiKey, host); } @@ -38,12 +44,22 @@ export class TelemetryClientFactory { this.configService.get('ENTITLEMENT_URL') || 'https://betterdb.com/api/v1/entitlements'; const url = new URL(entitlementUrl); - url.pathname = url.pathname.replace(/\/entitlements$/, '/telemetry'); + const telemetryPath = url.pathname.replace(/\/entitlements$/, '/telemetry'); + if (telemetryPath === url.pathname) { + this.logger.warn( + `ENTITLEMENT_URL path "${url.pathname}" does not end with "/entitlements". ` + + 'Cannot derive telemetry endpoint. Falling back to noop telemetry.', + ); + return new NoopTelemetryClientAdapter(); + } + url.pathname = telemetryPath; return new HttpTelemetryClientAdapter(url.toString()); } default: - console.warn(`Unknown TELEMETRY_PROVIDER "${provider}". Falling back to noop telemetry.`); + this.logger.warn( + `Unknown TELEMETRY_PROVIDER value. Falling back to noop telemetry.`, + ); return new NoopTelemetryClientAdapter(); } } diff --git a/apps/api/src/telemetry/telemetry.module.ts b/apps/api/src/telemetry/telemetry.module.ts index 5c15189e..7fc26e16 100644 --- a/apps/api/src/telemetry/telemetry.module.ts +++ b/apps/api/src/telemetry/telemetry.module.ts @@ -1,4 +1,4 @@ -import { Module, OnModuleDestroy, Inject } from '@nestjs/common'; +import { Module, OnModuleDestroy, Inject, Logger } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TelemetryController } from './telemetry.controller'; import { UsageTelemetryService } from './usage-telemetry.service'; @@ -22,9 +22,13 @@ import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; exports: [UsageTelemetryService], }) export class TelemetryModule implements OnModuleDestroy { + private readonly logger = new Logger(TelemetryModule.name); + constructor(@Inject('TELEMETRY_CLIENT') private readonly telemetryClient: TelemetryPort) {} async onModuleDestroy(): Promise { - await this.telemetryClient.shutdown(); + await this.telemetryClient.shutdown().catch((err) => { + this.logger.warn('Telemetry shutdown error (non-fatal):', err); + }); } } diff --git a/apps/api/src/telemetry/usage-telemetry.service.ts b/apps/api/src/telemetry/usage-telemetry.service.ts index 956c3e86..2c45ad65 100644 --- a/apps/api/src/telemetry/usage-telemetry.service.ts +++ b/apps/api/src/telemetry/usage-telemetry.service.ts @@ -44,16 +44,17 @@ export class UsageTelemetryService implements OnModuleInit { } private sendEvent(eventType: string, payload?: Record): void { + if (!this.instanceId) return; this.telemetryClient.capture({ distinctId: this.instanceId, event: eventType, properties: { + ...payload, version: this.version, tier: this.tier, deploymentMode: this.deploymentMode, workspaceName: this.workspaceName, timestamp: Date.now(), - ...payload, }, }); } From 80e806b590129315477484c0e19610cb121e4f46 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 11:42:01 +0300 Subject: [PATCH 09/24] chore: add readonly to constructor-only fields in UsageTelemetryService --- apps/api/src/telemetry/usage-telemetry.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/telemetry/usage-telemetry.service.ts b/apps/api/src/telemetry/usage-telemetry.service.ts index 2c45ad65..bea58f8a 100644 --- a/apps/api/src/telemetry/usage-telemetry.service.ts +++ b/apps/api/src/telemetry/usage-telemetry.service.ts @@ -6,10 +6,10 @@ import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; @Injectable() export class UsageTelemetryService implements OnModuleInit { private instanceId: string; - private version: string; + private readonly version: string; private tier: string; - private deploymentMode: string; - private workspaceName: string | undefined; + private readonly deploymentMode: string; + private readonly workspaceName: string | undefined; constructor( @Inject('TELEMETRY_CLIENT') private readonly telemetryClient: TelemetryPort, From b5388fe3ebe570039c84ac33ef2e0d53f02ab3c1 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 12:18:45 +0300 Subject: [PATCH 10/24] fix: restore try/catch in sendEvent to keep telemetry fire-and-forget --- .../src/telemetry/usage-telemetry.service.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/api/src/telemetry/usage-telemetry.service.ts b/apps/api/src/telemetry/usage-telemetry.service.ts index bea58f8a..c49dda8d 100644 --- a/apps/api/src/telemetry/usage-telemetry.service.ts +++ b/apps/api/src/telemetry/usage-telemetry.service.ts @@ -45,18 +45,22 @@ export class UsageTelemetryService implements OnModuleInit { private sendEvent(eventType: string, payload?: Record): void { if (!this.instanceId) return; - this.telemetryClient.capture({ - distinctId: this.instanceId, - event: eventType, - properties: { - ...payload, - version: this.version, - tier: this.tier, - deploymentMode: this.deploymentMode, - workspaceName: this.workspaceName, - timestamp: Date.now(), - }, - }); + try { + this.telemetryClient.capture({ + distinctId: this.instanceId, + event: eventType, + properties: { + ...payload, + version: this.version, + tier: this.tier, + deploymentMode: this.deploymentMode, + workspaceName: this.workspaceName, + timestamp: Date.now(), + }, + }); + } catch { + // fire-and-forget — telemetry must never crash the app + } } async trackAppStart(): Promise { From 981a65896712842531d082abcbe53c0e65fd7ba7 Mon Sep 17 00:00:00 2001 From: Petar Dzhambazov Date: Thu, 2 Apr 2026 12:30:30 +0300 Subject: [PATCH 11/24] feature: PosthogTelemetryClientAdapter with posthog-node (#83) feature: implement PosthogTelemetryClientAdapter with posthog-node Thin wrapper around posthog-node: capture(), identify(), and shutdown() delegate directly to the PostHog client. Remove stub warning from factory since adapter is now real. Closes #73 --- apps/api/package.json | 1 + .../posthog-telemetry-client.adapter.spec.ts | 65 +++++++++++++++++++ .../posthog-telemetry-client.adapter.ts | 36 +++++++--- .../src/telemetry/telemetry-client.factory.ts | 4 -- pnpm-lock.yaml | 24 +++++++ 5 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/telemetry/__tests__/posthog-telemetry-client.adapter.spec.ts diff --git a/apps/api/package.json b/apps/api/package.json index 555cfcd3..4c65e747 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -68,6 +68,7 @@ "lru-cache": "^11.2.7", "ollama": "^0.6.3", "pg": "^8.20.0", + "posthog-node": "^5.28.11", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/apps/api/src/telemetry/__tests__/posthog-telemetry-client.adapter.spec.ts b/apps/api/src/telemetry/__tests__/posthog-telemetry-client.adapter.spec.ts new file mode 100644 index 00000000..6952a1e5 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/posthog-telemetry-client.adapter.spec.ts @@ -0,0 +1,65 @@ +import { PosthogTelemetryClientAdapter } from '../adapters/posthog-telemetry-client.adapter'; + +const mockCapture = jest.fn(); +const mockIdentify = jest.fn(); +const mockShutdown = jest.fn().mockResolvedValue(undefined); + +jest.mock('posthog-node', () => ({ + PostHog: jest.fn().mockImplementation(() => ({ + capture: mockCapture, + identify: mockIdentify, + shutdown: mockShutdown, + })), +})); + +describe('PosthogTelemetryClientAdapter', () => { + let adapter: PosthogTelemetryClientAdapter; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new PosthogTelemetryClientAdapter('phc_test_key'); + }); + + it('should initialize PostHog client with api key', () => { + const { PostHog } = require('posthog-node'); + expect(PostHog).toHaveBeenCalledWith('phc_test_key', expect.objectContaining({})); + }); + + it('should initialize PostHog client with custom host', () => { + jest.clearAllMocks(); + const _adapter = new PosthogTelemetryClientAdapter('phc_key', 'https://ph.example.com'); + const { PostHog } = require('posthog-node'); + expect(PostHog).toHaveBeenCalledWith( + 'phc_key', + expect.objectContaining({ host: 'https://ph.example.com' }), + ); + }); + + it('should delegate capture to posthog.capture', () => { + adapter.capture({ + distinctId: 'inst-123', + event: 'app_start', + properties: { version: '0.12.0' }, + }); + + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: 'inst-123', + event: 'app_start', + properties: { version: '0.12.0' }, + }); + }); + + it('should delegate identify to posthog.identify', () => { + adapter.identify('inst-123', { tier: 'pro', version: '0.12.0' }); + + expect(mockIdentify).toHaveBeenCalledWith({ + distinctId: 'inst-123', + properties: { tier: 'pro', version: '0.12.0' }, + }); + }); + + it('should delegate shutdown to posthog.shutdown', async () => { + await adapter.shutdown(); + expect(mockShutdown).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts b/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts index 0b37286e..47aa6bfa 100644 --- a/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts +++ b/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts @@ -1,13 +1,31 @@ +import { PostHog } from 'posthog-node'; import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; -// TODO(#73): Replace stubs with real posthog-node implementation export class PosthogTelemetryClientAdapter implements TelemetryPort { - constructor( - private readonly apiKey: string, - private readonly host?: string, - ) {} - - capture(_event: TelemetryEvent): void {} - identify(_distinctId: string, _properties: Record): void {} - async shutdown(): Promise {} + private readonly client: PostHog; + + constructor(apiKey: string, host?: string) { + this.client = new PostHog(apiKey, { + ...(host ? { host } : {}), + }); + } + + capture(event: TelemetryEvent): void { + this.client.capture({ + distinctId: event.distinctId, + event: event.event, + properties: event.properties, + }); + } + + identify(distinctId: string, properties: Record): void { + this.client.identify({ + distinctId, + properties, + }); + } + + async shutdown(): Promise { + await this.client.shutdown(); + } } diff --git a/apps/api/src/telemetry/telemetry-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts index dd67ee4b..c017fe5a 100644 --- a/apps/api/src/telemetry/telemetry-client.factory.ts +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -32,10 +32,6 @@ export class TelemetryClientFactory { return new NoopTelemetryClientAdapter(); } const host = this.configService.get('POSTHOG_HOST'); - // TODO(#73): Replace stub with real posthog-node implementation - this.logger.warn( - 'PostHog adapter is not yet implemented (#73). Events will be discarded.', - ); return new PosthogTelemetryClientAdapter(apiKey, host); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 316d3307..7aec7320 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: pg: specifier: ^8.20.0 version: 8.20.0 + posthog-node: + specifier: ^5.28.11 + version: 5.28.11(rxjs@7.8.2) prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -1404,6 +1407,7 @@ packages: '@lancedb/lancedb@0.23.0': resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==} engines: {node: '>= 18'} + cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' @@ -2047,6 +2051,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@posthog/core@1.24.6': + resolution: {integrity: sha512-9WkcRKqmXSWIJcca6m3VwA9YbFd4HiG2hKEtDq6FcwEHlvfDhQQUZ5/sJZ47Fw8OtyNMHQ6rW4+COttk4Bg5NQ==} + '@prisma/adapter-pg@7.5.0': resolution: {integrity: sha512-EJx7OLULahcC3IjJgdx2qRDNCT+ToY2v66UkeETMCLhNOTgqVzRzYvOEphY7Zp0eHyzfkC33Edd/qqeadf9R4A==} @@ -5708,6 +5715,15 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + posthog-node@5.28.11: + resolution: {integrity: sha512-H4FOiqKUBO8SVXyXlU5tyifeS11hyTGVwBirFPR5rPtw8X6OFs5xVLx38YL7ZBLjaa9u8is+nIWXKBwWsZ2vlw==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -8331,6 +8347,8 @@ snapshots: dependencies: playwright: 1.57.0 + '@posthog/core@1.24.6': {} + '@prisma/adapter-pg@7.5.0': dependencies: '@prisma/driver-adapter-utils': 7.5.0 @@ -12200,6 +12218,12 @@ snapshots: postgres@3.4.7: {} + posthog-node@5.28.11(rxjs@7.8.2): + dependencies: + '@posthog/core': 1.24.6 + optionalDependencies: + rxjs: 7.8.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 From 57e4b459b52823c409697963d7df0d3fa94a85f5 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 12:34:53 +0300 Subject: [PATCH 12/24] feature: add GET /telemetry/config endpoint for frontend runtime configuration Returns instanceId, telemetryEnabled, provider, and optional posthog fields. Frontend uses this at runtime to initialize the correct telemetry client without build-time env vars. Closes #74 --- .../__tests__/telemetry-config.spec.ts | 104 ++++++++++++++++++ .../api/src/telemetry/telemetry.controller.ts | 40 ++++++- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/telemetry/__tests__/telemetry-config.spec.ts diff --git a/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts new file mode 100644 index 00000000..cd147423 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts @@ -0,0 +1,104 @@ +import { ConfigService } from '@nestjs/config'; +import { TelemetryController } from '../telemetry.controller'; +import { UsageTelemetryService } from '../usage-telemetry.service'; +import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; +import { LicenseService } from '@proprietary/licenses'; + +function createMockConfigService( + env: Record = {}, +): ConfigService { + return { + get: jest.fn( + (key: string, defaultValue?: string | boolean) => env[key] ?? defaultValue, + ), + } as unknown as ConfigService; +} + +const mockAdapter: TelemetryPort = { + capture: jest.fn(), + identify: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), +}; + +function createController( + configService: ConfigService, + licenseService?: Partial, +): TelemetryController { + const service = new UsageTelemetryService( + mockAdapter, + configService, + licenseService as LicenseService | undefined, + ); + return new TelemetryController( + service, + configService, + licenseService as LicenseService | undefined, + ); +} + +describe('GET /telemetry/config', () => { + it('should return telemetry config with posthog provider and API key', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'posthog', + POSTHOG_API_KEY: 'phc_test_key', + POSTHOG_HOST: 'https://ph.example.com', + BETTERDB_TELEMETRY: true, + }); + const licenseService = { + getInstanceId: jest.fn().mockReturnValue('test-instance-id'), + }; + + const controller = createController(configService, licenseService); + const config = controller.getConfig(); + + expect(config).toEqual({ + instanceId: 'test-instance-id', + telemetryEnabled: true, + provider: 'posthog', + posthogApiKey: 'phc_test_key', + posthogHost: 'https://ph.example.com', + }); + }); + + it('should omit posthog fields when provider is not posthog', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'http', + BETTERDB_TELEMETRY: true, + }); + + const controller = createController(configService); + const config = controller.getConfig(); + + expect(config).toEqual({ + instanceId: '', + telemetryEnabled: true, + provider: 'http', + }); + expect(config).not.toHaveProperty('posthogApiKey'); + expect(config).not.toHaveProperty('posthogHost'); + }); + + it('should return telemetryEnabled false when BETTERDB_TELEMETRY is false', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'noop', + BETTERDB_TELEMETRY: false, + }); + + const controller = createController(configService); + const config = controller.getConfig(); + + expect(config.telemetryEnabled).toBe(false); + }); + + it('should return empty instanceId when licenseService is absent', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'posthog', + POSTHOG_API_KEY: 'phc_key', + }); + + const controller = createController(configService); + const config = controller.getConfig(); + + expect(config.instanceId).toBe(''); + }); +}); diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index 3f6a5991..a47b1abf 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -1,12 +1,48 @@ -import { Controller, Post, Body, BadRequestException } from '@nestjs/common'; +import { Controller, Post, Get, Body, BadRequestException, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { LicenseService } from '@proprietary/licenses'; import { UsageTelemetryService } from './usage-telemetry.service'; +interface TelemetryConfig { + instanceId: string; + telemetryEnabled: boolean; + provider: string; + posthogApiKey?: string; + posthogHost?: string; +} + const ALLOWED_EVENT_TYPES = ['interaction_after_idle', 'page_view', 'connection_switch'] as const; type AllowedEventType = typeof ALLOWED_EVENT_TYPES[number]; @Controller('telemetry') export class TelemetryController { - constructor(private readonly usageTelemetry: UsageTelemetryService) {} + constructor( + private readonly usageTelemetry: UsageTelemetryService, + private readonly configService: ConfigService, + @Optional() private readonly licenseService?: LicenseService, + ) {} + + @Get('config') + getConfig(): TelemetryConfig { + const provider = this.configService.get('TELEMETRY_PROVIDER', 'posthog'); + const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY') !== false; + const instanceId = this.licenseService?.getInstanceId() ?? ''; + + const config: TelemetryConfig = { + instanceId, + telemetryEnabled, + provider, + }; + + if (provider === 'posthog') { + const apiKey = this.configService.get('POSTHOG_API_KEY'); + const host = this.configService.get('POSTHOG_HOST'); + if (apiKey) config.posthogApiKey = apiKey; + if (host) config.posthogHost = host; + } + + return config; + } @Post('event') async trackEvent( From db159465aa941a0c9284d4ebea8efa6bd2c49a09 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 13:01:40 +0300 Subject: [PATCH 13/24] fix: handle both boolean and string 'false' for BETTERDB_TELEMETRY in config endpoint --- .../telemetry/__tests__/telemetry-config.spec.ts | 14 +++++++++++--- apps/api/src/telemetry/telemetry.controller.ts | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts index cd147423..858c565e 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts @@ -78,16 +78,24 @@ describe('GET /telemetry/config', () => { expect(config).not.toHaveProperty('posthogHost'); }); - it('should return telemetryEnabled false when BETTERDB_TELEMETRY is false', () => { + it('should return telemetryEnabled false when BETTERDB_TELEMETRY is boolean false', () => { const configService = createMockConfigService({ TELEMETRY_PROVIDER: 'noop', BETTERDB_TELEMETRY: false, }); const controller = createController(configService); - const config = controller.getConfig(); + expect(controller.getConfig().telemetryEnabled).toBe(false); + }); - expect(config.telemetryEnabled).toBe(false); + it('should return telemetryEnabled false when BETTERDB_TELEMETRY is string "false"', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'noop', + BETTERDB_TELEMETRY: 'false', + }); + + const controller = createController(configService); + expect(controller.getConfig().telemetryEnabled).toBe(false); }); it('should return empty instanceId when licenseService is absent', () => { diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index a47b1abf..c8646533 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -25,7 +25,8 @@ export class TelemetryController { @Get('config') getConfig(): TelemetryConfig { const provider = this.configService.get('TELEMETRY_PROVIDER', 'posthog'); - const telemetryEnabled = this.configService.get('BETTERDB_TELEMETRY') !== false; + const rawTelemetry = this.configService.get('BETTERDB_TELEMETRY'); + const telemetryEnabled = rawTelemetry !== false && rawTelemetry !== 'false'; const instanceId = this.licenseService?.getInstanceId() ?? ''; const config: TelemetryConfig = { From 8301dba4a291176ac3bc45921457dad44212a67e Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 13:03:37 +0300 Subject: [PATCH 14/24] refactor: improve readability and type clarity in TelemetryController --- apps/api/src/telemetry/telemetry.controller.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index c8646533..c50a4162 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -12,7 +12,7 @@ interface TelemetryConfig { } const ALLOWED_EVENT_TYPES = ['interaction_after_idle', 'page_view', 'connection_switch'] as const; -type AllowedEventType = typeof ALLOWED_EVENT_TYPES[number]; +type AllowedEventType = (typeof ALLOWED_EVENT_TYPES)[number]; @Controller('telemetry') export class TelemetryController { @@ -25,8 +25,8 @@ export class TelemetryController { @Get('config') getConfig(): TelemetryConfig { const provider = this.configService.get('TELEMETRY_PROVIDER', 'posthog'); - const rawTelemetry = this.configService.get('BETTERDB_TELEMETRY'); - const telemetryEnabled = rawTelemetry !== false && rawTelemetry !== 'false'; + const rawTelemetryConfig = this.configService.get('BETTERDB_TELEMETRY'); + const telemetryEnabled = rawTelemetryConfig !== false && rawTelemetryConfig !== 'false'; const instanceId = this.licenseService?.getInstanceId() ?? ''; const config: TelemetryConfig = { @@ -71,7 +71,8 @@ export class TelemetryController { throw new BadRequestException('payload.totalConnections must be a number'); } const dbType = typeof body.payload?.dbType === 'string' ? body.payload.dbType : 'unknown'; - const dbVersion = typeof body.payload?.dbVersion === 'string' ? body.payload.dbVersion : 'unknown'; + const dbVersion = + typeof body.payload?.dbVersion === 'string' ? body.payload.dbVersion : 'unknown'; await this.usageTelemetry.trackDbSwitch(totalConnections, dbType, dbVersion); } From b2df1240a89bf78199d6de8044c850536f441984 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 14:37:37 +0300 Subject: [PATCH 15/24] feature: extract telemetry event types to shared, add DTO with class-validator Move frontend/backend telemetry event constants and types to @betterdb/shared for frontend type safety. Add TelemetryEventDto with class-validator @IsIn and @IsObject for NestJS validation. Refactor controller to use DTO, switch statement, and private handlers. --- .../src/telemetry/dto/telemetry-event.dto.ts | 10 +++ .../api/src/telemetry/telemetry.controller.ts | 68 +++++++++++-------- packages/shared/src/index.ts | 1 + packages/shared/src/types/telemetry.ts | 18 +++++ 4 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/telemetry/dto/telemetry-event.dto.ts create mode 100644 packages/shared/src/types/telemetry.ts diff --git a/apps/api/src/telemetry/dto/telemetry-event.dto.ts b/apps/api/src/telemetry/dto/telemetry-event.dto.ts new file mode 100644 index 00000000..f4efb1a5 --- /dev/null +++ b/apps/api/src/telemetry/dto/telemetry-event.dto.ts @@ -0,0 +1,10 @@ +import { IsIn, IsObject } from 'class-validator'; +import { FRONTEND_TELEMETRY_EVENTS, FrontendTelemetryEvent } from '@betterdb/shared'; + +export class TelemetryEventDto { + @IsIn(FRONTEND_TELEMETRY_EVENTS) + eventType: FrontendTelemetryEvent; + + @IsObject() + payload: Record; +} diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index c50a4162..dccb570c 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -1,7 +1,9 @@ import { Controller, Post, Get, Body, BadRequestException, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LicenseService } from '@proprietary/licenses'; +import { FrontendTelemetryEvent } from '@betterdb/shared'; import { UsageTelemetryService } from './usage-telemetry.service'; +import { TelemetryEventDto } from './dto/telemetry-event.dto'; interface TelemetryConfig { instanceId: string; @@ -11,9 +13,6 @@ interface TelemetryConfig { posthogHost?: string; } -const ALLOWED_EVENT_TYPES = ['interaction_after_idle', 'page_view', 'connection_switch'] as const; -type AllowedEventType = (typeof ALLOWED_EVENT_TYPES)[number]; - @Controller('telemetry') export class TelemetryController { constructor( @@ -46,36 +45,45 @@ export class TelemetryController { } @Post('event') - async trackEvent( - @Body() body: { eventType: string; payload: Record }, - ): Promise<{ ok: true }> { - if (!ALLOWED_EVENT_TYPES.includes(body.eventType as AllowedEventType)) { - throw new BadRequestException(`Invalid eventType: ${body.eventType}`); + async trackEvent(@Body() body: TelemetryEventDto): Promise<{ ok: true }> { + switch (body.eventType as FrontendTelemetryEvent) { + case 'interaction_after_idle': + await this.handleInteractionAfterIdle(body.payload); + break; + case 'page_view': + await this.handlePageView(body.payload); + break; + case 'connection_switch': + await this.handleConnectionSwitch(body.payload); + break; + } + + return { ok: true }; + } + + private async handleInteractionAfterIdle(payload: Record): Promise { + const idleDurationMs = payload?.idleDurationMs; + if (typeof idleDurationMs !== 'number') { + throw new BadRequestException('payload.idleDurationMs must be a number'); } + await this.usageTelemetry.trackInteractionAfterIdle(idleDurationMs); + } - if (body.eventType === 'interaction_after_idle') { - const idleDurationMs = body.payload?.idleDurationMs; - if (typeof idleDurationMs !== 'number') { - throw new BadRequestException('payload.idleDurationMs must be a number'); - } - await this.usageTelemetry.trackInteractionAfterIdle(idleDurationMs); - } else if (body.eventType === 'page_view') { - const path = body.payload?.path; - if (typeof path !== 'string') { - throw new BadRequestException('payload.path must be a string'); - } - await this.usageTelemetry.trackPageView(path); - } else if (body.eventType === 'connection_switch') { - const totalConnections = body.payload?.totalConnections; - if (typeof totalConnections !== 'number') { - throw new BadRequestException('payload.totalConnections must be a number'); - } - const dbType = typeof body.payload?.dbType === 'string' ? body.payload.dbType : 'unknown'; - const dbVersion = - typeof body.payload?.dbVersion === 'string' ? body.payload.dbVersion : 'unknown'; - await this.usageTelemetry.trackDbSwitch(totalConnections, dbType, dbVersion); + private async handlePageView(payload: Record): Promise { + const path = payload?.path; + if (typeof path !== 'string') { + throw new BadRequestException('payload.path must be a string'); } + await this.usageTelemetry.trackPageView(path); + } - return { ok: true }; + private async handleConnectionSwitch(payload: Record): Promise { + const totalConnections = payload?.totalConnections; + if (typeof totalConnections !== 'number') { + throw new BadRequestException('payload.totalConnections must be a number'); + } + const dbType = typeof payload?.dbType === 'string' ? payload.dbType : 'unknown'; + const dbVersion = typeof payload?.dbVersion === 'string' ? payload.dbVersion : 'unknown'; + await this.usageTelemetry.trackDbSwitch(totalConnections, dbType, dbVersion); } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 11b0385f..19acb988 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,3 +15,4 @@ export * from './webhooks/index'; export * from './types/vector-index-snapshots'; export * from './types/migration'; export * from './types/metric-forecasting.types'; +export * from './types/telemetry'; diff --git a/packages/shared/src/types/telemetry.ts b/packages/shared/src/types/telemetry.ts new file mode 100644 index 00000000..34810126 --- /dev/null +++ b/packages/shared/src/types/telemetry.ts @@ -0,0 +1,18 @@ +export const FRONTEND_TELEMETRY_EVENTS = [ + 'interaction_after_idle', + 'page_view', + 'connection_switch', +] as const; + +export type FrontendTelemetryEvent = (typeof FRONTEND_TELEMETRY_EVENTS)[number]; + +export const BACKEND_TELEMETRY_EVENTS = [ + 'app_start', + 'db_connect', + 'db_switch', + 'mcp_tool_call', +] as const; + +export type BackendTelemetryEvent = (typeof BACKEND_TELEMETRY_EVENTS)[number]; + +export type TelemetryEventType = FrontendTelemetryEvent | BackendTelemetryEvent; From b99ccf1ece4521e1746d8ffbac3ef7162678693a Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 14:41:37 +0300 Subject: [PATCH 16/24] fix: remove posthog API key and host from telemetry config endpoint --- .../__tests__/telemetry-config.spec.ts | 17 +++++------------ apps/api/src/telemetry/telemetry.controller.ts | 9 --------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts index 858c565e..f1bf0547 100644 --- a/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts +++ b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts @@ -37,11 +37,9 @@ function createController( } describe('GET /telemetry/config', () => { - it('should return telemetry config with posthog provider and API key', () => { + it('should return telemetry config with instanceId and provider', () => { const configService = createMockConfigService({ TELEMETRY_PROVIDER: 'posthog', - POSTHOG_API_KEY: 'phc_test_key', - POSTHOG_HOST: 'https://ph.example.com', BETTERDB_TELEMETRY: true, }); const licenseService = { @@ -55,25 +53,20 @@ describe('GET /telemetry/config', () => { instanceId: 'test-instance-id', telemetryEnabled: true, provider: 'posthog', - posthogApiKey: 'phc_test_key', - posthogHost: 'https://ph.example.com', }); }); - it('should omit posthog fields when provider is not posthog', () => { + it('should not expose posthog API key or host', () => { const configService = createMockConfigService({ - TELEMETRY_PROVIDER: 'http', + TELEMETRY_PROVIDER: 'posthog', + POSTHOG_API_KEY: 'phc_secret', + POSTHOG_HOST: 'https://ph.example.com', BETTERDB_TELEMETRY: true, }); const controller = createController(configService); const config = controller.getConfig(); - expect(config).toEqual({ - instanceId: '', - telemetryEnabled: true, - provider: 'http', - }); expect(config).not.toHaveProperty('posthogApiKey'); expect(config).not.toHaveProperty('posthogHost'); }); diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index dccb570c..3101871d 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -9,8 +9,6 @@ interface TelemetryConfig { instanceId: string; telemetryEnabled: boolean; provider: string; - posthogApiKey?: string; - posthogHost?: string; } @Controller('telemetry') @@ -34,13 +32,6 @@ export class TelemetryController { provider, }; - if (provider === 'posthog') { - const apiKey = this.configService.get('POSTHOG_API_KEY'); - const host = this.configService.get('POSTHOG_HOST'); - if (apiKey) config.posthogApiKey = apiKey; - if (host) config.posthogHost = host; - } - return config; } From 0b027c2e359d829d61dbc2dc36c062e2266ad4cb Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 14:55:27 +0300 Subject: [PATCH 17/24] fix: add default case to event switch to throw on unhandled event types --- apps/api/src/telemetry/telemetry.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index 3101871d..3ff8a4ee 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -47,6 +47,8 @@ export class TelemetryController { case 'connection_switch': await this.handleConnectionSwitch(body.payload); break; + default: + throw new BadRequestException(`Unhandled eventType: ${body.eventType}`); } return { ok: true }; From 3d387ea153a395012d2669a8297ac7b77d1ebc98 Mon Sep 17 00:00:00 2001 From: Petar Dzhambazov Date: Thu, 2 Apr 2026 15:35:50 +0300 Subject: [PATCH 18/24] feature: frontend TelemetryConfigProvider + ApiTelemetryClient + hook refactor (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: frontend TelemetryConfigProvider, ApiTelemetryClient, and hook refactor Add TelemetryClient interface, NoopTelemetryClient, ApiTelemetryClient. TelemetryConfigProvider fetches GET /telemetry/config on mount, selects the correct client, and exposes it via useTelemetry() context hook. Falls back to ApiTelemetryClient on config fetch failure. Refactor useNavigationTracker and useIdleTracker to use useTelemetry() instead of direct fetchApi calls. useConnection telemetry left as-is since it creates context at a level above the provider. Closes #75 * fix: remove nonexistent 'api' provider case, use 'http' consistently * chore: move useTelemetry hook to hooks/ directory * chore: replace TelemetryConfigProvider with singleton useTelemetry hook Remove the context provider — config loading now lives in useTelemetry hook with a module-level singleton. Config is fetched once, cached, and shared across all consumers. Hook returns { client, ready }. No provider wrapping needed in App. * chore: simplify useTelemetry to async function with module-level promise * feature: block app render until telemetry client is ready in ServerStartupGuard * chore: use TanStack Query for telemetry config fetching in useTelemetry * chore: use 30min stale time and default retry for telemetry config query --- .../web/src/components/ServerStartupGuard.tsx | 4 +- .../src/hooks/__tests__/useTelemetry.test.ts | 78 +++++++++++++++++++ apps/web/src/hooks/useIdleTracker.ts | 17 ++-- apps/web/src/hooks/useNavigationTracker.ts | 15 ++-- apps/web/src/hooks/useTelemetry.ts | 56 +++++++++++++ .../__tests__/telemetry-clients.test.ts | 59 ++++++++++++++ .../telemetry/clients/api-telemetry-client.ts | 17 ++++ .../clients/noop-telemetry-client.ts | 7 ++ .../telemetry/telemetry-client.interface.ts | 5 ++ 9 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useTelemetry.test.ts create mode 100644 apps/web/src/hooks/useTelemetry.ts create mode 100644 apps/web/src/telemetry/__tests__/telemetry-clients.test.ts create mode 100644 apps/web/src/telemetry/clients/api-telemetry-client.ts create mode 100644 apps/web/src/telemetry/clients/noop-telemetry-client.ts create mode 100644 apps/web/src/telemetry/telemetry-client.interface.ts diff --git a/apps/web/src/components/ServerStartupGuard.tsx b/apps/web/src/components/ServerStartupGuard.tsx index c7b41885..a6c3d8e1 100644 --- a/apps/web/src/components/ServerStartupGuard.tsx +++ b/apps/web/src/components/ServerStartupGuard.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, ReactNode } from 'react'; +import { useTelemetry } from '../hooks/useTelemetry'; interface ServerStartupGuardProps { children: ReactNode; @@ -21,6 +22,7 @@ export function ServerStartupGuard({ children }: ServerStartupGuardProps) { const [serverReady, setServerReady] = useState(false); const [error, setError] = useState(null); const [retryCount, setRetryCount] = useState(0); + const { ready: telemetryReady } = useTelemetry(); useEffect(() => { let retries = 0; @@ -98,7 +100,7 @@ export function ServerStartupGuard({ children }: ServerStartupGuardProps) { ); } - if (!serverReady) { + if (!serverReady || !telemetryReady) { return (
diff --git a/apps/web/src/hooks/__tests__/useTelemetry.test.ts b/apps/web/src/hooks/__tests__/useTelemetry.test.ts new file mode 100644 index 00000000..a13c523f --- /dev/null +++ b/apps/web/src/hooks/__tests__/useTelemetry.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { renderHookWithQuery } from '../../test/test-utils'; + +vi.mock('../../api/client', () => ({ + fetchApi: vi.fn(), +})); + +import { fetchApi } from '../../api/client'; +import { useTelemetry } from '../useTelemetry'; + +const mockFetchApi = vi.mocked(fetchApi); + +describe('useTelemetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should resolve to ApiTelemetryClient for http provider', async () => { + mockFetchApi.mockResolvedValue({ + instanceId: 'inst-123', + telemetryEnabled: true, + provider: 'http', + }); + + const { result } = renderHookWithQuery(() => useTelemetry()); + + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + + expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); + }); + + it('should return NoopTelemetryClient when telemetryEnabled is false', async () => { + mockFetchApi.mockResolvedValue({ + instanceId: 'inst-123', + telemetryEnabled: false, + provider: 'posthog', + }); + + const { result } = renderHookWithQuery(() => useTelemetry()); + + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + + expect(result.current.client.constructor.name).toBe('NoopTelemetryClient'); + }); + + it('should fall back to ApiTelemetryClient when config fetch fails', async () => { + mockFetchApi.mockRejectedValue(new Error('network error')); + + const { result } = renderHookWithQuery(() => useTelemetry()); + + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + + expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); + }); + + it('should return NoopTelemetryClient for noop provider', async () => { + mockFetchApi.mockResolvedValue({ + instanceId: 'inst-123', + telemetryEnabled: true, + provider: 'noop', + }); + + const { result } = renderHookWithQuery(() => useTelemetry()); + + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + + expect(result.current.client.constructor.name).toBe('NoopTelemetryClient'); + }); +}); diff --git a/apps/web/src/hooks/useIdleTracker.ts b/apps/web/src/hooks/useIdleTracker.ts index fc64fa3d..f8341d46 100644 --- a/apps/web/src/hooks/useIdleTracker.ts +++ b/apps/web/src/hooks/useIdleTracker.ts @@ -1,28 +1,23 @@ import { useEffect, useRef } from 'react'; -import { fetchApi } from '../api/client'; +import { useTelemetry } from './useTelemetry'; const IDLE_THRESHOLD_MS = 5 * 60 * 1000; const THROTTLE_MS = 30_000; -export function useIdleTracker() { +export function useIdleTracker(): void { const lastInteractionTime = useRef(Date.now()); const lastThrottleUpdate = useRef(Date.now()); + const { client } = useTelemetry(); useEffect(() => { - const handler = () => { + const handler = (): void => { const now = Date.now(); const idleDuration = now - lastInteractionTime.current; if (idleDuration >= IDLE_THRESHOLD_MS) { lastInteractionTime.current = now; lastThrottleUpdate.current = now; - fetchApi('/telemetry/event', { - method: 'POST', - body: JSON.stringify({ - eventType: 'interaction_after_idle', - payload: { idleDurationMs: idleDuration }, - }), - }).catch(() => {}); + client.capture('interaction_after_idle', { idleDurationMs: idleDuration }); } else if (now - lastThrottleUpdate.current >= THROTTLE_MS) { lastInteractionTime.current = now; lastThrottleUpdate.current = now; @@ -39,5 +34,5 @@ export function useIdleTracker() { document.removeEventListener(event, handler); } }; - }, []); + }, [client]); } diff --git a/apps/web/src/hooks/useNavigationTracker.ts b/apps/web/src/hooks/useNavigationTracker.ts index 62b5ebe4..428e58cf 100644 --- a/apps/web/src/hooks/useNavigationTracker.ts +++ b/apps/web/src/hooks/useNavigationTracker.ts @@ -1,21 +1,16 @@ import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { fetchApi } from '../api/client'; +import { useTelemetry } from './useTelemetry'; -export function useNavigationTracker() { +export function useNavigationTracker(): void { const location = useLocation(); const previousPath = useRef(null); + const { client } = useTelemetry(); useEffect(() => { if (previousPath.current !== null && previousPath.current !== location.pathname) { - fetchApi('/telemetry/event', { - method: 'POST', - body: JSON.stringify({ - eventType: 'page_view', - payload: { path: location.pathname }, - }), - }).catch(() => {}); + client.capture('page_view', { path: location.pathname }); } previousPath.current = location.pathname; - }, [location.pathname]); + }, [location.pathname, client]); } diff --git a/apps/web/src/hooks/useTelemetry.ts b/apps/web/src/hooks/useTelemetry.ts new file mode 100644 index 00000000..9dab1a15 --- /dev/null +++ b/apps/web/src/hooks/useTelemetry.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchApi } from '../api/client'; +import type { TelemetryClient } from '../telemetry/telemetry-client.interface'; +import { ApiTelemetryClient } from '../telemetry/clients/api-telemetry-client'; +import { NoopTelemetryClient } from '../telemetry/clients/noop-telemetry-client'; + +interface TelemetryConfig { + instanceId: string; + telemetryEnabled: boolean; + provider: string; +} + +interface TelemetryState { + client: TelemetryClient; + ready: boolean; +} + +function createClient(config: TelemetryConfig): TelemetryClient { + if (!config.telemetryEnabled || config.provider === 'noop') { + return new NoopTelemetryClient(); + } + + switch (config.provider) { + case 'posthog': + // PosthogTelemetryClient will be added in #76 + return new ApiTelemetryClient(); + case 'http': + default: + return new ApiTelemetryClient(); + } +} + +const noopClient = new NoopTelemetryClient(); +const fallbackClient = new ApiTelemetryClient(); + +export function useTelemetry(): TelemetryState { + const { data: config, isSuccess, isError } = useQuery({ + queryKey: ['telemetry-config'], + queryFn: () => fetchApi('/telemetry/config'), + staleTime: 30 * 60 * 1000, + }); + + const client = useMemo(() => { + if (isError) return fallbackClient; + if (!config) return noopClient; + + const newClient = createClient(config); + if (config.instanceId) { + newClient.identify(config.instanceId, { provider: config.provider }); + } + return newClient; + }, [config, isError]); + + return { client, ready: isSuccess || isError }; +} diff --git a/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts b/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts new file mode 100644 index 00000000..9c74bf43 --- /dev/null +++ b/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NoopTelemetryClient } from '../clients/noop-telemetry-client'; +import { ApiTelemetryClient } from '../clients/api-telemetry-client'; + +vi.mock('../../api/client', () => ({ + fetchApi: vi.fn().mockResolvedValue({ ok: true }), +})); + +import { fetchApi } from '../../api/client'; + +const mockFetchApi = vi.mocked(fetchApi); + +describe('NoopTelemetryClient', () => { + it('should implement capture without side effects', () => { + const client = new NoopTelemetryClient(); + expect(() => client.capture('app_start')).not.toThrow(); + }); + + it('should implement identify without side effects', () => { + const client = new NoopTelemetryClient(); + expect(() => client.identify('id', {})).not.toThrow(); + }); + + it('should implement shutdown without side effects', () => { + const client = new NoopTelemetryClient(); + expect(() => client.shutdown()).not.toThrow(); + }); +}); + +describe('ApiTelemetryClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should POST capture events to /telemetry/event', () => { + const client = new ApiTelemetryClient(); + client.capture('page_view', { path: '/dashboard' }); + + expect(mockFetchApi).toHaveBeenCalledWith('/telemetry/event', { + method: 'POST', + body: JSON.stringify({ + eventType: 'page_view', + payload: { path: '/dashboard' }, + }), + }); + }); + + it('should not call fetchApi on identify', () => { + const client = new ApiTelemetryClient(); + client.identify('inst-123', { tier: 'pro' }); + expect(mockFetchApi).not.toHaveBeenCalled(); + }); + + it('should not call fetchApi on shutdown', () => { + const client = new ApiTelemetryClient(); + client.shutdown(); + expect(mockFetchApi).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/telemetry/clients/api-telemetry-client.ts b/apps/web/src/telemetry/clients/api-telemetry-client.ts new file mode 100644 index 00000000..2a9e322b --- /dev/null +++ b/apps/web/src/telemetry/clients/api-telemetry-client.ts @@ -0,0 +1,17 @@ +import type { TelemetryClient } from '../telemetry-client.interface'; +import { fetchApi } from '../../api/client'; + +export class ApiTelemetryClient implements TelemetryClient { + capture(event: string, properties?: Record): void { + fetchApi('/telemetry/event', { + method: 'POST', + body: JSON.stringify({ + eventType: event, + payload: properties ?? {}, + }), + }).catch(() => {}); + } + + identify(_distinctId: string, _properties: Record): void {} + shutdown(): void {} +} diff --git a/apps/web/src/telemetry/clients/noop-telemetry-client.ts b/apps/web/src/telemetry/clients/noop-telemetry-client.ts new file mode 100644 index 00000000..295514dc --- /dev/null +++ b/apps/web/src/telemetry/clients/noop-telemetry-client.ts @@ -0,0 +1,7 @@ +import type { TelemetryClient } from '../telemetry-client.interface'; + +export class NoopTelemetryClient implements TelemetryClient { + capture(_event: string, _properties?: Record): void {} + identify(_distinctId: string, _properties: Record): void {} + shutdown(): void {} +} diff --git a/apps/web/src/telemetry/telemetry-client.interface.ts b/apps/web/src/telemetry/telemetry-client.interface.ts new file mode 100644 index 00000000..0e770cd5 --- /dev/null +++ b/apps/web/src/telemetry/telemetry-client.interface.ts @@ -0,0 +1,5 @@ +export interface TelemetryClient { + capture(event: string, properties?: Record): void; + identify(distinctId: string, properties: Record): void; + shutdown(): void; +} From 45a936b386395619d0a3a3f6320ecd70f889b984 Mon Sep 17 00:00:00 2001 From: Petar Dzhambazov Date: Thu, 2 Apr 2026 16:45:40 +0300 Subject: [PATCH 19/24] feature: frontend PosthogTelemetryClient with posthog-js (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: frontend PosthogTelemetryClient with posthog-js Thin wrapper around posthog-js: capture() maps page_view to native $pageview, identify() and shutdown() delegate directly. Wired into useTelemetry hook — activated when backend returns provider=posthog and VITE_POSTHOG_API_KEY is set at build time. Falls back to ApiTelemetryClient when key is missing. Closes #76 * chore: add telemetry env vars to .env.example * chore: add frontend telemetry env vars to .env.example * fix: set Vite envDir to monorepo root so .env vars are loaded * refactor: update PostHog client to use instance-based API, adjust env vars and tests Switch from the global `posthog` instance to an instance-based approach with `PostHog`. Updated env vars for clarity (`VITE_POSTHOG_*` to `VITE_PUBLIC_POSTHOG_*`). Refactored `useTelemetry` to manage lifecycle via `useEffect` and updated tests to mock the new client structure. * fix: store posthog.init() instance, use || for empty string host fallback - Use returned PostHog instance from init() instead of global - Use || instead of ?? so empty string host falls back to default - Remove debug console.log --- .env.example | 10 + apps/web/package.json | 1 + apps/web/src/hooks/useTelemetry.ts | 32 +- .../posthog-telemetry-client.test.ts | 63 +++ .../clients/posthog-telemetry-client.ts | 31 ++ apps/web/vite.config.ts | 1 + pnpm-lock.yaml | 363 ++++++++++++++++-- 7 files changed, 451 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/telemetry/__tests__/posthog-telemetry-client.test.ts create mode 100644 apps/web/src/telemetry/clients/posthog-telemetry-client.ts diff --git a/.env.example b/.env.example index 8ff17beb..dbe2cf77 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,16 @@ LICENSE_CACHE_TTL_MS=3600000 LICENSE_MAX_STALE_MS=604800000 LICENSE_TIMEOUT_MS=10000 +# Telemetry Configuration +TELEMETRY_PROVIDER=posthog +POSTHOG_API_KEY= +POSTHOG_HOST=https://eu.i.posthog.com +BETTERDB_TELEMETRY=true + +# Frontend Telemetry (Vite build-time) +VITE_PUBLIC_POSTHOG_PROJECT_TOKEN= +VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + # Key Analytics Configuration (Pro tier required) KEY_ANALYTICS_SAMPLE_SIZE=10000 KEY_ANALYTICS_SCAN_BATCH_SIZE=1000 diff --git a/apps/web/package.json b/apps/web/package.json index e3bc2c1d..af52c568 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "d3": "^7.9.0", "date-fns": "^4.1.0", "lucide-react": "^1.0.1", + "posthog-js": "^1.364.6", "react": "^19.2.4", "react-day-picker": "^9.14.0", "react-dom": "^19.2.4", diff --git a/apps/web/src/hooks/useTelemetry.ts b/apps/web/src/hooks/useTelemetry.ts index 9dab1a15..0f1a2484 100644 --- a/apps/web/src/hooks/useTelemetry.ts +++ b/apps/web/src/hooks/useTelemetry.ts @@ -1,9 +1,10 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { fetchApi } from '../api/client'; import type { TelemetryClient } from '../telemetry/telemetry-client.interface'; import { ApiTelemetryClient } from '../telemetry/clients/api-telemetry-client'; import { NoopTelemetryClient } from '../telemetry/clients/noop-telemetry-client'; +import { PosthogTelemetryClient } from '../telemetry/clients/posthog-telemetry-client'; interface TelemetryConfig { instanceId: string; @@ -22,9 +23,14 @@ function createClient(config: TelemetryConfig): TelemetryClient { } switch (config.provider) { - case 'posthog': - // PosthogTelemetryClient will be added in #76 - return new ApiTelemetryClient(); + case 'posthog': { + const apiKey = import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN; + const host = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + if (!apiKey) { + return new ApiTelemetryClient(); + } + return new PosthogTelemetryClient(apiKey, host); + } case 'http': default: return new ApiTelemetryClient(); @@ -35,7 +41,11 @@ const noopClient = new NoopTelemetryClient(); const fallbackClient = new ApiTelemetryClient(); export function useTelemetry(): TelemetryState { - const { data: config, isSuccess, isError } = useQuery({ + const { + data: config, + isSuccess, + isError, + } = useQuery({ queryKey: ['telemetry-config'], queryFn: () => fetchApi('/telemetry/config'), staleTime: 30 * 60 * 1000, @@ -44,13 +54,17 @@ export function useTelemetry(): TelemetryState { const client = useMemo(() => { if (isError) return fallbackClient; if (!config) return noopClient; - const newClient = createClient(config); - if (config.instanceId) { - newClient.identify(config.instanceId, { provider: config.provider }); - } return newClient; }, [config, isError]); + useEffect(() => { + if (config?.instanceId) { + client.identify(config.instanceId, { provider: config.provider }); + } + return () => { + client.shutdown(); + }; + }, [client, config?.instanceId, config?.provider]); return { client, ready: isSuccess || isError }; } diff --git a/apps/web/src/telemetry/__tests__/posthog-telemetry-client.test.ts b/apps/web/src/telemetry/__tests__/posthog-telemetry-client.test.ts new file mode 100644 index 00000000..68f827f4 --- /dev/null +++ b/apps/web/src/telemetry/__tests__/posthog-telemetry-client.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockInstance } = vi.hoisted(() => ({ + mockInstance: { + capture: vi.fn(), + identify: vi.fn(), + reset: vi.fn(), + }, +})); + +vi.mock('posthog-js', () => ({ + default: { + init: vi.fn().mockReturnValue(mockInstance), + }, +})); + +import posthog from 'posthog-js'; +import { PosthogTelemetryClient } from '../clients/posthog-telemetry-client'; + +const mockInit = vi.mocked(posthog.init); + +describe('PosthogTelemetryClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInit.mockReturnValue(mockInstance as never); + }); + + it('should initialize posthog with API key and host', () => { + const _client = new PosthogTelemetryClient('phc_test_key', 'https://ph.example.com'); + + expect(mockInit).toHaveBeenCalledWith('phc_test_key', expect.objectContaining({ + api_host: 'https://ph.example.com', + })); + }); + + it('should map page_view to $pageview on capture', () => { + const client = new PosthogTelemetryClient('phc_key'); + client.capture('page_view', { path: '/dashboard' }); + + expect(mockInstance.capture).toHaveBeenCalledWith('$pageview', { path: '/dashboard' }); + }); + + it('should pass other events through unchanged', () => { + const client = new PosthogTelemetryClient('phc_key'); + client.capture('interaction_after_idle', { idleDurationMs: 300000 }); + + expect(mockInstance.capture).toHaveBeenCalledWith('interaction_after_idle', { idleDurationMs: 300000 }); + }); + + it('should delegate identify to posthog.identify', () => { + const client = new PosthogTelemetryClient('phc_key'); + client.identify('inst-123', { tier: 'pro', version: '0.12.0' }); + + expect(mockInstance.identify).toHaveBeenCalledWith('inst-123', { tier: 'pro', version: '0.12.0' }); + }); + + it('should call reset on shutdown', () => { + const client = new PosthogTelemetryClient('phc_key'); + client.shutdown(); + + expect(mockInstance.reset).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/telemetry/clients/posthog-telemetry-client.ts b/apps/web/src/telemetry/clients/posthog-telemetry-client.ts new file mode 100644 index 00000000..44805457 --- /dev/null +++ b/apps/web/src/telemetry/clients/posthog-telemetry-client.ts @@ -0,0 +1,31 @@ +import posthog, { type PostHog } from 'posthog-js'; +import type { TelemetryClient } from '../telemetry-client.interface'; + +const EVENT_MAP: Record = { + page_view: '$pageview', +}; + +export class PosthogTelemetryClient implements TelemetryClient { + private readonly client: PostHog; + + constructor(apiKey: string, host?: string) { + this.client = posthog.init(apiKey, { + api_host: host || 'https://us.i.posthog.com', + defaults: '2026-01-30', + capture_pageview: false, + capture_pageleave: false, + })!; + } + + capture(event: string, properties?: Record): void { + this.client.capture(EVENT_MAP[event] ?? event, properties); + } + + identify(distinctId: string, properties: Record): void { + this.client.identify(distinctId, properties); + } + + shutdown(): void { + this.client.reset(); + } +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a93b73ac..05cfdb4e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,6 +5,7 @@ import path from 'path'; export default defineConfig({ plugins: [react(), tailwindcss()], + envDir: path.resolve(__dirname, '../..'), server: { port: 5173, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aec7320..6f708c74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,16 +53,16 @@ importers: version: 0.23.0(apache-arrow@21.1.0) '@langchain/community': specifier: ^1.1.24 - version: 1.1.25(@browserbasehq/sdk@2.9.0)(@browserbasehq/stagehand@1.14.0(@playwright/test@1.57.0)(deepmerge@4.3.1)(dotenv@17.3.1)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(zod@4.3.6))(@ibm-cloud/watsonx-ai@1.7.6)(@lancedb/lancedb@0.23.0(apache-arrow@21.1.0))(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(d3-dsv@3.0.1)(hnswlib-node@3.0.0)(ibm-cloud-sdk-core@5.4.5)(ignore@7.0.5)(jsonwebtoken@9.0.3)(lodash@4.17.23)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(pg@8.20.0)(ws@8.20.0) + version: 1.1.25(@browserbasehq/sdk@2.9.0)(@browserbasehq/stagehand@1.14.0(@playwright/test@1.57.0)(deepmerge@4.3.1)(dotenv@17.3.1)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(zod@4.3.6))(@ibm-cloud/watsonx-ai@1.7.6)(@lancedb/lancedb@0.23.0(apache-arrow@21.1.0))(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(better-sqlite3@12.8.0)(d3-dsv@3.0.1)(hnswlib-node@3.0.0)(ibm-cloud-sdk-core@5.4.5)(ignore@7.0.5)(jsonwebtoken@9.0.3)(lodash@4.17.23)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(pg@8.20.0)(ws@8.20.0) '@langchain/core': specifier: ^1.1.35 - version: 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + version: 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) '@langchain/ollama': specifier: ^1.2.6 - version: 1.2.6(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + version: 1.2.6(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) '@langchain/textsplitters': specifier: ^1.0.1 - version: 1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + version: 1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) '@nestjs/common': specifier: ^11.1.17 version: 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -113,7 +113,7 @@ importers: version: 9.0.3 langchain: specifier: ^1.2.36 - version: 1.2.37(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(ws@8.20.0)(zod-to-json-schema@3.25.1(zod@4.3.6)) + version: 1.2.37(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(ws@8.20.0)(zod-to-json-schema@3.25.1(zod@4.3.6)) lru-cache: specifier: ^11.2.7 version: 11.2.7 @@ -235,6 +235,9 @@ importers: lucide-react: specifier: ^1.0.1 version: 1.6.0(react@19.2.4) + posthog-js: + specifier: ^1.364.6 + version: 1.364.6 react: specifier: ^19.2.4 version: 19.2.4 @@ -410,7 +413,7 @@ importers: devDependencies: '@langchain/core': specifier: ^1.1.35 - version: 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + version: 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) '@types/node': specifier: ^22.19.15 version: 22.19.15 @@ -2025,10 +2028,78 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.1': + resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} @@ -2054,6 +2125,9 @@ packages: '@posthog/core@1.24.6': resolution: {integrity: sha512-9WkcRKqmXSWIJcca6m3VwA9YbFd4HiG2hKEtDq6FcwEHlvfDhQQUZ5/sJZ47Fw8OtyNMHQ6rW4+COttk4Bg5NQ==} + '@posthog/types@1.364.6': + resolution: {integrity: sha512-bgw5FBgxiS+aBql0UxZApNgdIdhxjRuKAs/qWUHoRSNnE8tOLVewB/Hb5mzBQCbyQVSVDAkmHEZAa7ePgtqfhw==} + '@prisma/adapter-pg@7.5.0': resolution: {integrity: sha512-EJx7OLULahcC3IjJgdx2qRDNCT+ToY2v66UkeETMCLhNOTgqVzRzYvOEphY7Zp0eHyzfkC33Edd/qqeadf9R4A==} @@ -2113,6 +2187,36 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -2815,6 +2919,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -3664,6 +3771,9 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3922,6 +4032,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dotenv-expand@12.0.3: resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} engines: {node: '>=12'} @@ -4246,6 +4359,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -5715,6 +5831,9 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + posthog-js@1.364.6: + resolution: {integrity: sha512-igc1nGc7J3njFZyQBMMGbFgjz6zx/0wxumHNW/MizJgslLFvSmoH8nfNIi1JM6bX1QhuUa7KCTaTtzZADzG9lA==} + posthog-node@5.28.11: resolution: {integrity: sha512-H4FOiqKUBO8SVXyXlU5tyifeS11hyTGVwBirFPR5rPtw8X6OFs5xVLx38YL7ZBLjaa9u8is+nIWXKBwWsZ2vlw==} engines: {node: ^20.20.0 || >=22.22.0} @@ -5724,6 +5843,9 @@ packages: rxjs: optional: true + preact@10.29.0: + resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -5789,6 +5911,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5824,6 +5950,9 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -6779,6 +6908,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -7959,11 +8091,11 @@ snapshots: '@lancedb/lancedb-win32-arm64-msvc': 0.23.0 '@lancedb/lancedb-win32-x64-msvc': 0.23.0 - '@langchain/classic@1.0.25(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)': + '@langchain/classic@1.0.25(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) - '@langchain/openai': 1.3.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(ws@8.20.0) - '@langchain/textsplitters': 1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/openai': 1.3.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(ws@8.20.0) + '@langchain/textsplitters': 1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) handlebars: 4.7.8 js-yaml: 4.1.1 jsonpointer: 5.0.1 @@ -7972,7 +8104,7 @@ snapshots: yaml: 2.8.2 zod: 4.3.6 optionalDependencies: - langsmith: 0.4.5(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6)) + langsmith: 0.4.5(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6)) transitivePeerDependencies: - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-proto' @@ -7980,18 +8112,18 @@ snapshots: - openai - ws - '@langchain/community@1.1.25(@browserbasehq/sdk@2.9.0)(@browserbasehq/stagehand@1.14.0(@playwright/test@1.57.0)(deepmerge@4.3.1)(dotenv@17.3.1)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(zod@4.3.6))(@ibm-cloud/watsonx-ai@1.7.6)(@lancedb/lancedb@0.23.0(apache-arrow@21.1.0))(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(d3-dsv@3.0.1)(hnswlib-node@3.0.0)(ibm-cloud-sdk-core@5.4.5)(ignore@7.0.5)(jsonwebtoken@9.0.3)(lodash@4.17.23)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(pg@8.20.0)(ws@8.20.0)': + '@langchain/community@1.1.25(@browserbasehq/sdk@2.9.0)(@browserbasehq/stagehand@1.14.0(@playwright/test@1.57.0)(deepmerge@4.3.1)(dotenv@17.3.1)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(zod@4.3.6))(@ibm-cloud/watsonx-ai@1.7.6)(@lancedb/lancedb@0.23.0(apache-arrow@21.1.0))(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(better-sqlite3@12.8.0)(d3-dsv@3.0.1)(hnswlib-node@3.0.0)(ibm-cloud-sdk-core@5.4.5)(ignore@7.0.5)(jsonwebtoken@9.0.3)(lodash@4.17.23)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(pg@8.20.0)(ws@8.20.0)': dependencies: '@browserbasehq/stagehand': 1.14.0(@playwright/test@1.57.0)(deepmerge@4.3.1)(dotenv@17.3.1)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(zod@4.3.6) '@ibm-cloud/watsonx-ai': 1.7.6 - '@langchain/classic': 1.0.25(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) - '@langchain/openai': 1.3.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(ws@8.20.0) + '@langchain/classic': 1.0.25(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/openai': 1.3.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(ws@8.20.0) binary-extensions: 2.3.0 flat: 5.0.2 ibm-cloud-sdk-core: 5.4.5 js-yaml: 4.1.1 - langsmith: 0.4.5(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6)) + langsmith: 0.4.5(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6)) math-expression-evaluator: 2.0.7 openai: 6.32.0(ws@8.20.0)(zod@4.3.6) uuid: 10.0.0 @@ -8013,7 +8145,7 @@ snapshots: - '@opentelemetry/sdk-trace-base' - peggy - '@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)': + '@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 @@ -8021,7 +8153,7 @@ snapshots: camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.21 - langsmith: 0.5.13(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + langsmith: 0.5.13(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) mustache: 4.2.0 p-queue: 6.6.2 uuid: 11.1.0 @@ -8033,32 +8165,32 @@ snapshots: - openai - ws - '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) uuid: 10.0.0 - '@langchain/langgraph-checkpoint@1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': + '@langchain/langgraph-checkpoint@1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) uuid: 10.0.0 - '@langchain/langgraph-sdk@1.8.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@langchain/langgraph-sdk@1.8.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@types/json-schema': 7.0.15 p-queue: 9.1.0 p-retry: 7.1.1 uuid: 13.0.0 optionalDependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@langchain/langgraph@1.2.5(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': + '@langchain/langgraph@1.2.5(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) - '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) - '@langchain/langgraph-sdk': 1.8.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + '@langchain/langgraph-sdk': 1.8.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@standard-schema/spec': 1.1.0 uuid: 10.0.0 zod: 4.3.6 @@ -8070,24 +8202,24 @@ snapshots: - svelte - vue - '@langchain/ollama@1.2.6(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': + '@langchain/ollama@1.2.6(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) ollama: 0.6.3 uuid: 10.0.0 - '@langchain/openai@1.3.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(ws@8.20.0)': + '@langchain/openai@1.3.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(ws@8.20.0)': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) js-tiktoken: 1.0.21 openai: 6.32.0(ws@8.20.0)(zod@4.3.6) zod: 4.3.6 transitivePeerDependencies: - ws - '@langchain/textsplitters@1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': + '@langchain/textsplitters@1.0.1(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) js-tiktoken: 1.0.21 '@lukeed/csprng@1.1.0': {} @@ -8328,8 +8460,82 @@ snapshots: dependencies: consola: 3.4.2 + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/semantic-conventions@1.40.0': {} + '@oxc-project/types@0.122.0': {} '@paralleldrive/cuid2@2.3.1': @@ -8349,6 +8555,8 @@ snapshots: '@posthog/core@1.24.6': {} + '@posthog/types@1.364.6': {} + '@prisma/adapter-pg@7.5.0': dependencies: '@prisma/driver-adapter-utils': 7.5.0 @@ -8437,6 +8645,29 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -9101,6 +9332,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/use-sync-external-store@0.0.6': {} '@types/uuid@10.0.0': {} @@ -9978,6 +10212,8 @@ snapshots: cookiejar@2.1.4: {} + core-js@3.49.0: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -10226,6 +10462,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv-expand@12.0.3: dependencies: dotenv: 16.6.1 @@ -10637,6 +10877,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -11575,12 +11817,12 @@ snapshots: kleur@3.0.3: {} - langchain@1.2.37(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(ws@8.20.0)(zod-to-json-schema@3.25.1(zod@4.3.6)): + langchain@1.2.37(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(ws@8.20.0)(zod-to-json-schema@3.25.1(zod@4.3.6)): dependencies: - '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) - '@langchain/langgraph': 1.2.5(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) - '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) - langsmith: 0.5.13(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/core': 1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/langgraph': 1.2.5(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.36(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + langsmith: 0.5.13(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) uuid: 11.1.0 zod: 4.3.6 transitivePeerDependencies: @@ -11595,7 +11837,7 @@ snapshots: - ws - zod-to-json-schema - langsmith@0.4.5(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6)): + langsmith@0.4.5(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -11605,9 +11847,10 @@ snapshots: uuid: 10.0.0 optionalDependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) openai: 6.32.0(ws@8.20.0)(zod@4.3.6) - langsmith@0.5.13(@opentelemetry/api@1.9.0)(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0): + langsmith@0.5.13(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.32.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0): dependencies: '@types/uuid': 10.0.0 chalk: 5.6.2 @@ -11617,6 +11860,7 @@ snapshots: uuid: 10.0.0 optionalDependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) openai: 6.32.0(ws@8.20.0)(zod@4.3.6) ws: 8.20.0 @@ -12218,12 +12462,30 @@ snapshots: postgres@3.4.7: {} + posthog-js@1.364.6: + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@posthog/core': 1.24.6 + '@posthog/types': 1.364.6 + core-js: 3.49.0 + dompurify: 3.3.3 + fflate: 0.4.8 + preact: 10.29.0 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.2.0 + posthog-node@5.28.11(rxjs@7.8.2): dependencies: '@posthog/core': 1.24.6 optionalDependencies: rxjs: 7.8.2 + preact@10.29.0: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -12304,6 +12566,21 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.15 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -12334,6 +12611,8 @@ snapshots: dependencies: side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} + querystringify@2.2.0: {} quick-format-unescaped@4.0.4: {} @@ -13306,6 +13585,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + web-vitals@5.2.0: {} + webidl-conversions@3.0.1: {} webpack-node-externals@3.0.0: {} From 0409c232997210bf9bb08559e865c7ee4005f898 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 16:59:11 +0300 Subject: [PATCH 20/24] fix: use module-level singleton for telemetry client to prevent per-component reset --- .../src/hooks/__tests__/useTelemetry.test.ts | 3 +- apps/web/src/hooks/useTelemetry.ts | 44 +++++++++++++------ 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/apps/web/src/hooks/__tests__/useTelemetry.test.ts b/apps/web/src/hooks/__tests__/useTelemetry.test.ts index a13c523f..37ab8236 100644 --- a/apps/web/src/hooks/__tests__/useTelemetry.test.ts +++ b/apps/web/src/hooks/__tests__/useTelemetry.test.ts @@ -7,13 +7,14 @@ vi.mock('../../api/client', () => ({ })); import { fetchApi } from '../../api/client'; -import { useTelemetry } from '../useTelemetry'; +import { useTelemetry, _resetTelemetryClient } from '../useTelemetry'; const mockFetchApi = vi.mocked(fetchApi); describe('useTelemetry', () => { beforeEach(() => { vi.clearAllMocks(); + _resetTelemetryClient(); }); it('should resolve to ApiTelemetryClient for http provider', async () => { diff --git a/apps/web/src/hooks/useTelemetry.ts b/apps/web/src/hooks/useTelemetry.ts index 0f1a2484..6015b5b3 100644 --- a/apps/web/src/hooks/useTelemetry.ts +++ b/apps/web/src/hooks/useTelemetry.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { fetchApi } from '../api/client'; import type { TelemetryClient } from '../telemetry/telemetry-client.interface'; @@ -37,8 +37,15 @@ function createClient(config: TelemetryConfig): TelemetryClient { } } +let sharedClient: TelemetryClient | null = null; +let identifiedInstanceId: string | null = null; const noopClient = new NoopTelemetryClient(); -const fallbackClient = new ApiTelemetryClient(); + +/** @internal test-only */ +export function _resetTelemetryClient(): void { + sharedClient = null; + identifiedInstanceId = null; +} export function useTelemetry(): TelemetryState { const { @@ -51,20 +58,29 @@ export function useTelemetry(): TelemetryState { staleTime: 30 * 60 * 1000, }); - const client = useMemo(() => { - if (isError) return fallbackClient; - if (!config) return noopClient; - const newClient = createClient(config); - return newClient; - }, [config, isError]); + const [client, setClient] = useState(sharedClient ?? noopClient); + useEffect(() => { - if (config?.instanceId) { - client.identify(config.instanceId, { provider: config.provider }); + if (sharedClient) { + setClient(sharedClient); + return; + } + + if (isError) { + sharedClient = new ApiTelemetryClient(); + setClient(sharedClient); + return; } - return () => { - client.shutdown(); - }; - }, [client, config?.instanceId, config?.provider]); + + if (config) { + sharedClient = createClient(config); + if (config.instanceId && identifiedInstanceId !== config.instanceId) { + sharedClient.identify(config.instanceId, { provider: config.provider }); + identifiedInstanceId = config.instanceId; + } + setClient(sharedClient); + } + }, [config, isError]); return { client, ready: isSuccess || isError }; } From 313a0ba8209cdc0818679319d9bd09f83853412f Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 17:01:29 +0300 Subject: [PATCH 21/24] chore: remove unused backend telemetry type exports from shared package --- packages/shared/src/types/telemetry.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/shared/src/types/telemetry.ts b/packages/shared/src/types/telemetry.ts index 34810126..618b4ba0 100644 --- a/packages/shared/src/types/telemetry.ts +++ b/packages/shared/src/types/telemetry.ts @@ -5,14 +5,3 @@ export const FRONTEND_TELEMETRY_EVENTS = [ ] as const; export type FrontendTelemetryEvent = (typeof FRONTEND_TELEMETRY_EVENTS)[number]; - -export const BACKEND_TELEMETRY_EVENTS = [ - 'app_start', - 'db_connect', - 'db_switch', - 'mcp_tool_call', -] as const; - -export type BackendTelemetryEvent = (typeof BACKEND_TELEMETRY_EVENTS)[number]; - -export type TelemetryEventType = FrontendTelemetryEvent | BackendTelemetryEvent; From b7762bddf93c1d2e4c9edc8307a6264e6d2e70d8 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 17:28:57 +0300 Subject: [PATCH 22/24] chore: remove NoopTelemetryClient from frontend, use ApiTelemetryClient as fallback --- .../src/hooks/__tests__/useTelemetry.test.ts | 15 +++--- apps/web/src/hooks/useTelemetry.ts | 53 +++++-------------- .../__tests__/telemetry-clients.test.ts | 18 ------- .../clients/noop-telemetry-client.ts | 7 --- 4 files changed, 20 insertions(+), 73 deletions(-) delete mode 100644 apps/web/src/telemetry/clients/noop-telemetry-client.ts diff --git a/apps/web/src/hooks/__tests__/useTelemetry.test.ts b/apps/web/src/hooks/__tests__/useTelemetry.test.ts index 37ab8236..1580f8a2 100644 --- a/apps/web/src/hooks/__tests__/useTelemetry.test.ts +++ b/apps/web/src/hooks/__tests__/useTelemetry.test.ts @@ -7,14 +7,13 @@ vi.mock('../../api/client', () => ({ })); import { fetchApi } from '../../api/client'; -import { useTelemetry, _resetTelemetryClient } from '../useTelemetry'; +import { useTelemetry } from '../useTelemetry'; const mockFetchApi = vi.mocked(fetchApi); describe('useTelemetry', () => { beforeEach(() => { vi.clearAllMocks(); - _resetTelemetryClient(); }); it('should resolve to ApiTelemetryClient for http provider', async () => { @@ -33,7 +32,7 @@ describe('useTelemetry', () => { expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); }); - it('should return NoopTelemetryClient when telemetryEnabled is false', async () => { + it('should return ApiTelemetryClient when telemetryEnabled is false', async () => { mockFetchApi.mockResolvedValue({ instanceId: 'inst-123', telemetryEnabled: false, @@ -46,10 +45,10 @@ describe('useTelemetry', () => { expect(result.current.ready).toBe(true); }); - expect(result.current.client.constructor.name).toBe('NoopTelemetryClient'); + expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); }); - it('should fall back to ApiTelemetryClient when config fetch fails', async () => { + it('should return ApiTelemetryClient when config fetch fails', async () => { mockFetchApi.mockRejectedValue(new Error('network error')); const { result } = renderHookWithQuery(() => useTelemetry()); @@ -61,11 +60,11 @@ describe('useTelemetry', () => { expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); }); - it('should return NoopTelemetryClient for noop provider', async () => { + it('should return ApiTelemetryClient for unknown provider', async () => { mockFetchApi.mockResolvedValue({ instanceId: 'inst-123', telemetryEnabled: true, - provider: 'noop', + provider: 'unknown', }); const { result } = renderHookWithQuery(() => useTelemetry()); @@ -74,6 +73,6 @@ describe('useTelemetry', () => { expect(result.current.ready).toBe(true); }); - expect(result.current.client.constructor.name).toBe('NoopTelemetryClient'); + expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); }); }); diff --git a/apps/web/src/hooks/useTelemetry.ts b/apps/web/src/hooks/useTelemetry.ts index 6015b5b3..cd29bacd 100644 --- a/apps/web/src/hooks/useTelemetry.ts +++ b/apps/web/src/hooks/useTelemetry.ts @@ -1,9 +1,7 @@ -import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { fetchApi } from '../api/client'; import type { TelemetryClient } from '../telemetry/telemetry-client.interface'; import { ApiTelemetryClient } from '../telemetry/clients/api-telemetry-client'; -import { NoopTelemetryClient } from '../telemetry/clients/noop-telemetry-client'; import { PosthogTelemetryClient } from '../telemetry/clients/posthog-telemetry-client'; interface TelemetryConfig { @@ -17,36 +15,33 @@ interface TelemetryState { ready: boolean; } +const clientsMap = new Map(); +clientsMap.set('http', new ApiTelemetryClient()); + function createClient(config: TelemetryConfig): TelemetryClient { - if (!config.telemetryEnabled || config.provider === 'noop') { - return new NoopTelemetryClient(); + if (!config.telemetryEnabled) { + return clientsMap.get('http')!; } switch (config.provider) { case 'posthog': { + if (clientsMap.has('posthog')) { + return clientsMap.get('posthog')!; + } const apiKey = import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN; const host = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; if (!apiKey) { - return new ApiTelemetryClient(); + return clientsMap.get('http')!; } - return new PosthogTelemetryClient(apiKey, host); + clientsMap.set('posthog', new PosthogTelemetryClient(apiKey, host)); + return clientsMap.get('posthog')!; } case 'http': default: - return new ApiTelemetryClient(); + return clientsMap.get('http')!; } } -let sharedClient: TelemetryClient | null = null; -let identifiedInstanceId: string | null = null; -const noopClient = new NoopTelemetryClient(); - -/** @internal test-only */ -export function _resetTelemetryClient(): void { - sharedClient = null; - identifiedInstanceId = null; -} - export function useTelemetry(): TelemetryState { const { data: config, @@ -58,29 +53,7 @@ export function useTelemetry(): TelemetryState { staleTime: 30 * 60 * 1000, }); - const [client, setClient] = useState(sharedClient ?? noopClient); - - useEffect(() => { - if (sharedClient) { - setClient(sharedClient); - return; - } - - if (isError) { - sharedClient = new ApiTelemetryClient(); - setClient(sharedClient); - return; - } - - if (config) { - sharedClient = createClient(config); - if (config.instanceId && identifiedInstanceId !== config.instanceId) { - sharedClient.identify(config.instanceId, { provider: config.provider }); - identifiedInstanceId = config.instanceId; - } - setClient(sharedClient); - } - }, [config, isError]); + const client = config ? createClient(config) : clientsMap.get('http')!; return { client, ready: isSuccess || isError }; } diff --git a/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts b/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts index 9c74bf43..ca4daffa 100644 --- a/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts +++ b/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NoopTelemetryClient } from '../clients/noop-telemetry-client'; import { ApiTelemetryClient } from '../clients/api-telemetry-client'; vi.mock('../../api/client', () => ({ @@ -10,23 +9,6 @@ import { fetchApi } from '../../api/client'; const mockFetchApi = vi.mocked(fetchApi); -describe('NoopTelemetryClient', () => { - it('should implement capture without side effects', () => { - const client = new NoopTelemetryClient(); - expect(() => client.capture('app_start')).not.toThrow(); - }); - - it('should implement identify without side effects', () => { - const client = new NoopTelemetryClient(); - expect(() => client.identify('id', {})).not.toThrow(); - }); - - it('should implement shutdown without side effects', () => { - const client = new NoopTelemetryClient(); - expect(() => client.shutdown()).not.toThrow(); - }); -}); - describe('ApiTelemetryClient', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/web/src/telemetry/clients/noop-telemetry-client.ts b/apps/web/src/telemetry/clients/noop-telemetry-client.ts deleted file mode 100644 index 295514dc..00000000 --- a/apps/web/src/telemetry/clients/noop-telemetry-client.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TelemetryClient } from '../telemetry-client.interface'; - -export class NoopTelemetryClient implements TelemetryClient { - capture(_event: string, _properties?: Record): void {} - identify(_distinctId: string, _properties: Record): void {} - shutdown(): void {} -} From 4e8dec52fe87662b5b291770f5088a2d540b90e1 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 17:31:57 +0300 Subject: [PATCH 23/24] fix: call identify with instanceId when creating PostHog frontend client --- apps/web/src/hooks/useTelemetry.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useTelemetry.ts b/apps/web/src/hooks/useTelemetry.ts index cd29bacd..cd06e0be 100644 --- a/apps/web/src/hooks/useTelemetry.ts +++ b/apps/web/src/hooks/useTelemetry.ts @@ -33,8 +33,12 @@ function createClient(config: TelemetryConfig): TelemetryClient { if (!apiKey) { return clientsMap.get('http')!; } - clientsMap.set('posthog', new PosthogTelemetryClient(apiKey, host)); - return clientsMap.get('posthog')!; + const client = new PosthogTelemetryClient(apiKey, host); + if (config.instanceId) { + client.identify(config.instanceId, { provider: config.provider }); + } + clientsMap.set('posthog', client); + return client; } case 'http': default: From 893ab63ebf921b1318b4f57e8d17035804d7bb87 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 2 Apr 2026 18:07:13 +0300 Subject: [PATCH 24/24] feature: route LicenseService heartbeat through TelemetryPort adapter (#79) Inject TELEMETRY_CLIENT into LicenseService as @Optional() and delegate heartbeat telemetry_ping events through the adapter via capture() instead of direct HTTP. Version info continues via validateLicense() calls to the entitlement URL. sendStartupError() remains unchanged. --- apps/api/src/telemetry/telemetry.module.ts | 2 +- .../__tests__/license.service.spec.ts | 108 ++++++++++++++++++ proprietary/licenses/license.service.ts | 57 ++++----- 3 files changed, 131 insertions(+), 36 deletions(-) diff --git a/apps/api/src/telemetry/telemetry.module.ts b/apps/api/src/telemetry/telemetry.module.ts index 7fc26e16..28e82003 100644 --- a/apps/api/src/telemetry/telemetry.module.ts +++ b/apps/api/src/telemetry/telemetry.module.ts @@ -19,7 +19,7 @@ import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; }, UsageTelemetryService, ], - exports: [UsageTelemetryService], + exports: ['TELEMETRY_CLIENT', UsageTelemetryService], }) export class TelemetryModule implements OnModuleDestroy { private readonly logger = new Logger(TelemetryModule.name); diff --git a/proprietary/licenses/__tests__/license.service.spec.ts b/proprietary/licenses/__tests__/license.service.spec.ts index ce444a24..39ab1e02 100644 --- a/proprietary/licenses/__tests__/license.service.spec.ts +++ b/proprietary/licenses/__tests__/license.service.spec.ts @@ -1,10 +1,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { LicenseService } from '../license.service'; +import { TelemetryPort } from '@app/common/interfaces/telemetry-port.interface'; + +const createMockTelemetryClient = (): jest.Mocked => ({ + capture: jest.fn(), + identify: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), +}); describe('LicenseService', () => { let service: LicenseService; let mockFetch: jest.SpyInstance; + let mockTelemetryClient: jest.Mocked; const originalEnv = process.env; @@ -18,6 +26,7 @@ describe('LicenseService', () => { beforeEach(async () => { jest.resetModules(); process.env = { ...originalEnv }; + mockTelemetryClient = createMockTelemetryClient(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -28,6 +37,10 @@ describe('LicenseService', () => { get: jest.fn(), }, }, + { + provide: 'TELEMETRY_CLIENT', + useValue: mockTelemetryClient, + }, ], }).compile(); @@ -186,6 +199,101 @@ describe('LicenseService', () => { }); }); + describe('heartbeat telemetry via adapter', () => { + let heartbeatService: LicenseService; + let heartbeatMock: jest.Mocked; + + beforeEach(async () => { + delete process.env.BETTERDB_LICENSE_KEY; + delete process.env.BETTERDB_TELEMETRY; + heartbeatMock = createMockTelemetryClient(); + + const module = await Test.createTestingModule({ + providers: [ + LicenseService, + { provide: ConfigService, useValue: { get: jest.fn() } }, + { provide: 'TELEMETRY_CLIENT', useValue: heartbeatMock }, + ], + }).compile(); + heartbeatService = module.get(LicenseService); + }); + + afterEach(() => { + heartbeatService.onModuleDestroy(); + }); + + it('should delegate heartbeat to telemetry adapter via capture()', () => { + const stats = { version: 'unknown', uptime: 42 }; + (heartbeatService as any).sendHeartbeat(stats); + + expect(heartbeatMock.capture).toHaveBeenCalledTimes(1); + expect(heartbeatMock.capture).toHaveBeenCalledWith({ + distinctId: expect.any(String), + event: 'telemetry_ping', + properties: expect.objectContaining({ + tier: 'community', + deploymentMode: 'self-hosted', + version: 'unknown', + uptime: 42, + }), + }); + }); + + it('should not call adapter when telemetry is disabled', async () => { + process.env.BETTERDB_TELEMETRY = 'false'; + + const module = await Test.createTestingModule({ + providers: [ + LicenseService, + { provide: ConfigService, useValue: { get: jest.fn() } }, + { provide: 'TELEMETRY_CLIENT', useValue: heartbeatMock }, + ], + }).compile(); + const telemetryOffService = module.get(LicenseService); + + (telemetryOffService as any).sendHeartbeat({ version: 'test' }); + + expect(heartbeatMock.capture).not.toHaveBeenCalled(); + }); + + it('should not throw when telemetry client is not injected', async () => { + const module = await Test.createTestingModule({ + providers: [ + LicenseService, + { provide: ConfigService, useValue: { get: jest.fn() } }, + ], + }).compile(); + const noClientService = module.get(LicenseService); + + expect(() => (noClientService as any).sendHeartbeat({ version: 'test' })).not.toThrow(); + }); + }); + + describe('version info via validateLicense', () => { + it('should store version info from entitlement response', async () => { + mockFetch.mockResolvedValue(createMockResponse({ + valid: true, + tier: 'community', + expiresAt: null, + latestVersion: '0.5.0', + releaseUrl: 'https://example.com/v0.5.0', + })); + + await service.validateLicense(); + + const info = service.getVersionInfo(); + expect(info.latest).toBe('0.5.0'); + expect(info.releaseUrl).toBe('https://example.com/v0.5.0'); + }); + + it('should not throw when entitlement fetch fails', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await service.validateLicense(); + expect(result.tier).toBe('community'); + }); + }); + describe('keyed validation', () => { let keyedService: LicenseService; diff --git a/proprietary/licenses/license.service.ts b/proprietary/licenses/license.service.ts index 74edcd5e..0f712ffc 100644 --- a/proprietary/licenses/license.service.ts +++ b/proprietary/licenses/license.service.ts @@ -1,9 +1,10 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHash } from 'crypto'; import { compare, valid as validSemver } from 'semver'; import { Tier, Feature, TIER_FEATURES, EntitlementResponse } from './types'; import type { VersionInfo } from '@betterdb/shared'; +import { TelemetryPort } from '@app/common/interfaces/telemetry-port.interface'; interface CachedEntitlement { response: EntitlementResponse; @@ -33,7 +34,10 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { private releaseUrl: string | null = null; private versionCheckedAt: number | null = null; - constructor(private readonly config: ConfigService) { + constructor( + private readonly config: ConfigService, + @Inject('TELEMETRY_CLIENT') @Optional() private readonly telemetryClient?: TelemetryPort, + ) { this.currentVersion = process.env.APP_VERSION || process.env.npm_package_version || 'unknown'; this.licenseKey = process.env.BETTERDB_LICENSE_KEY || null; @@ -73,7 +77,10 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { if (this.telemetryEnabled) { this.heartbeatTimer = setInterval(() => { this.collectStats().then(stats => { - this.sendTelemetry('telemetry_ping', stats); + this.sendHeartbeat(stats); + }); + this.validateLicense().catch(() => { + // Version check is best-effort }); }, this.versionCheckIntervalMs); this.logger.log(`Telemetry heartbeat scheduled every ${this.versionCheckIntervalMs}ms`); @@ -175,48 +182,28 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { }; } - async sendTelemetry(eventType: string, data: Record = {}): Promise { - if (!this.telemetryEnabled) { + private sendHeartbeat(data: Record = {}): void { + if (!this.telemetryEnabled || !this.telemetryClient) { return; } - const payload = { - instanceId: this.instanceId, - eventType, - tier: this.getLicenseTier(), - deploymentMode: process.env.CLOUD_MODE === 'true' ? 'cloud' as const : 'self-hosted' as const, - ...data, - }; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - try { - const response = await fetch(this.entitlementUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal, + this.telemetryClient.capture({ + distinctId: this.instanceId, + event: 'telemetry_ping', + properties: { + tier: this.getLicenseTier(), + deploymentMode: + process.env.CLOUD_MODE === 'true' ? 'cloud' : 'self-hosted', + ...data, + }, }); - - // Store version info from telemetry response - if (response.ok) { - try { - const data = await response.json(); - if (data.latestVersion) { - this.setLatestVersion(data.latestVersion, data.releaseUrl); - } - } catch { - // No JSON in response - ignore - } - } } catch { // Telemetry is best-effort, don't log failures - } finally { - clearTimeout(timeout); } } + private getCommunityEntitlement(error?: string): EntitlementResponse { return { valid: !error,