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/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/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..635a92b1 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -4,87 +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().transform(v => v !== 'false').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__/noop-telemetry-client.adapter.spec.ts b/apps/api/src/telemetry/__tests__/noop-telemetry-client.adapter.spec.ts new file mode 100644 index 00000000..b9783271 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/noop-telemetry-client.adapter.spec.ts @@ -0,0 +1,26 @@ +import { TelemetryPort } from '../../common/interfaces/telemetry-port.interface'; +import { NoopTelemetryClientAdapter } from '../adapters/noop-telemetry-client.adapter'; + +describe('NoopTelemetryClientAdapter', () => { + let adapter: TelemetryPort; + + beforeEach(() => { + adapter = new NoopTelemetryClientAdapter(); + }); + + 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__/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/__tests__/telemetry-client.factory.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts new file mode 100644 index 00000000..dc17d044 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/telemetry-client.factory.spec.ts @@ -0,0 +1,99 @@ +import { Logger } from '@nestjs/common'; +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 = {}, +): ConfigService { + return { + get: jest.fn( + (key: string, defaultValue?: string | boolean) => env[key] ?? defaultValue, + ), + } as unknown as ConfigService; +} + +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); + expect(factory.createTelemetryClient()).toBeInstanceOf(NoopTelemetryClientAdapter); + }); + + 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 boolean 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 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'), + ); + }); + + 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'), + ); + }); + + 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-config.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts new file mode 100644 index 00000000..f1bf0547 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/telemetry-config.spec.ts @@ -0,0 +1,105 @@ +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 instanceId and provider', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'posthog', + 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', + }); + }); + + it('should not expose posthog API key or host', () => { + const configService = createMockConfigService({ + 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).not.toHaveProperty('posthogApiKey'); + expect(config).not.toHaveProperty('posthogHost'); + }); + + it('should return telemetryEnabled false when BETTERDB_TELEMETRY is boolean false', () => { + const configService = createMockConfigService({ + TELEMETRY_PROVIDER: 'noop', + BETTERDB_TELEMETRY: false, + }); + + const controller = createController(configService); + expect(controller.getConfig().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', () => { + 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/__tests__/telemetry-integration.spec.ts b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts new file mode 100644 index 00000000..97436b71 --- /dev/null +++ b/apps/api/src/telemetry/__tests__/telemetry-integration.spec.ts @@ -0,0 +1,92 @@ +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'; +import { ConfigService } from '@nestjs/config'; + +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', () => { + let mockAdapter: ReturnType; + + 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 }), + TelemetryModule, + ], + }) + .overrideProvider('TELEMETRY_CLIENT') + .useValue(mockAdapter) + .compile(); + + const service = module.get(UsageTelemetryService); + + await service.trackPageView('/dashboard'); + await service.trackAppStart(); + + expect(mockAdapter.capture).not.toHaveBeenCalled(); + }); + + 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' }), + }), + ); + + await module.close(); + }); +}); 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/noop-telemetry-client.adapter.ts b/apps/api/src/telemetry/adapters/noop-telemetry-client.adapter.ts new file mode 100644 index 00000000..797ecaa9 --- /dev/null +++ b/apps/api/src/telemetry/adapters/noop-telemetry-client.adapter.ts @@ -0,0 +1,7 @@ +import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; + +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/adapters/posthog-telemetry-client.adapter.ts b/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts new file mode 100644 index 00000000..47aa6bfa --- /dev/null +++ b/apps/api/src/telemetry/adapters/posthog-telemetry-client.adapter.ts @@ -0,0 +1,31 @@ +import { PostHog } from 'posthog-node'; +import { TelemetryPort, TelemetryEvent } from '../../common/interfaces/telemetry-port.interface'; + +export class PosthogTelemetryClientAdapter implements TelemetryPort { + 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/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-client.factory.ts b/apps/api/src/telemetry/telemetry-client.factory.ts new file mode 100644 index 00000000..c017fe5a --- /dev/null +++ b/apps/api/src/telemetry/telemetry-client.factory.ts @@ -0,0 +1,62 @@ +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'; +import { HttpTelemetryClientAdapter } from './adapters/http-telemetry-client.adapter'; +import { PosthogTelemetryClientAdapter } from './adapters/posthog-telemetry-client.adapter'; + +@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 || telemetryEnabled === 'false') { + return new NoopTelemetryClientAdapter(); + } + + const provider = this.configService.get('TELEMETRY_PROVIDER', 'posthog'); + + switch (provider) { + case 'noop': + return new NoopTelemetryClientAdapter(); + + case 'posthog': { + const apiKey = this.configService.get('POSTHOG_API_KEY'); + if (!apiKey) { + 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'); + return new PosthogTelemetryClientAdapter(apiKey, host); + } + + case 'http': { + const entitlementUrl = + this.configService.get('ENTITLEMENT_URL') || + 'https://betterdb.com/api/v1/entitlements'; + const url = new URL(entitlementUrl); + 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: + this.logger.warn( + `Unknown TELEMETRY_PROVIDER value. Falling back to noop telemetry.`, + ); + return new NoopTelemetryClientAdapter(); + } + } +} diff --git a/apps/api/src/telemetry/telemetry.controller.ts b/apps/api/src/telemetry/telemetry.controller.ts index 3f6a5991..3ff8a4ee 100644 --- a/apps/api/src/telemetry/telemetry.controller.ts +++ b/apps/api/src/telemetry/telemetry.controller.ts @@ -1,43 +1,82 @@ -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 { FrontendTelemetryEvent } from '@betterdb/shared'; import { UsageTelemetryService } from './usage-telemetry.service'; +import { TelemetryEventDto } from './dto/telemetry-event.dto'; -const ALLOWED_EVENT_TYPES = ['interaction_after_idle', 'page_view', 'connection_switch'] as const; -type AllowedEventType = typeof ALLOWED_EVENT_TYPES[number]; +interface TelemetryConfig { + instanceId: string; + telemetryEnabled: boolean; + provider: string; +} @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 rawTelemetryConfig = this.configService.get('BETTERDB_TELEMETRY'); + const telemetryEnabled = rawTelemetryConfig !== false && rawTelemetryConfig !== 'false'; + const instanceId = this.licenseService?.getInstanceId() ?? ''; + + const config: TelemetryConfig = { + instanceId, + telemetryEnabled, + provider, + }; + + return config; + } @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; + default: + throw new BadRequestException(`Unhandled eventType: ${body.eventType}`); + } + + 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/apps/api/src/telemetry/telemetry.module.ts b/apps/api/src/telemetry/telemetry.module.ts index 93289ca6..28e82003 100644 --- a/apps/api/src/telemetry/telemetry.module.ts +++ b/apps/api/src/telemetry/telemetry.module.ts @@ -1,10 +1,34 @@ -import { Module } 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'; +import { TelemetryClientFactory } from './telemetry-client.factory'; +import { TelemetryPort } from '../common/interfaces/telemetry-port.interface'; @Module({ + imports: [ConfigModule], controllers: [TelemetryController], - providers: [UsageTelemetryService], - exports: [UsageTelemetryService], + providers: [ + TelemetryClientFactory, + { + provide: 'TELEMETRY_CLIENT', + useFactory: (factory: TelemetryClientFactory): TelemetryPort => { + return factory.createTelemetryClient(); + }, + inject: [TelemetryClientFactory], + }, + UsageTelemetryService, + ], + exports: ['TELEMETRY_CLIENT', UsageTelemetryService], }) -export class TelemetryModule {} +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().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 a698457c..c49dda8d 100644 --- a/apps/api/src/telemetry/usage-telemetry.service.ts +++ b/apps/api/src/telemetry/usage-telemetry.service.ts @@ -1,74 +1,98 @@ -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 readonly version: string; + private tier: string; + private readonly deploymentMode: string; + private readonly 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 { + private sendEvent(eventType: string, payload?: Record): void { + if (!this.instanceId) return; 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, - timestamp: Date.now(), - payload, - }; - - await fetch(this.telemetryUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5000), + 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 + // fire-and-forget — telemetry must never crash the app } } 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); } } 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/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..1580f8a2 --- /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 ApiTelemetryClient 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('ApiTelemetryClient'); + }); + + it('should return 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 ApiTelemetryClient for unknown provider', async () => { + mockFetchApi.mockResolvedValue({ + instanceId: 'inst-123', + telemetryEnabled: true, + provider: 'unknown', + }); + + const { result } = renderHookWithQuery(() => useTelemetry()); + + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + + expect(result.current.client.constructor.name).toBe('ApiTelemetryClient'); + }); +}); 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..cd06e0be --- /dev/null +++ b/apps/web/src/hooks/useTelemetry.ts @@ -0,0 +1,63 @@ +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 { PosthogTelemetryClient } from '../telemetry/clients/posthog-telemetry-client'; + +interface TelemetryConfig { + instanceId: string; + telemetryEnabled: boolean; + provider: string; +} + +interface TelemetryState { + client: TelemetryClient; + ready: boolean; +} + +const clientsMap = new Map(); +clientsMap.set('http', new ApiTelemetryClient()); + +function createClient(config: TelemetryConfig): TelemetryClient { + 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 clientsMap.get('http')!; + } + 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: + return clientsMap.get('http')!; + } +} + +export function useTelemetry(): TelemetryState { + const { + data: config, + isSuccess, + isError, + } = useQuery({ + queryKey: ['telemetry-config'], + queryFn: () => fetchApi('/telemetry/config'), + staleTime: 30 * 60 * 1000, + }); + + const client = config ? createClient(config) : clientsMap.get('http')!; + + 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/__tests__/telemetry-clients.test.ts b/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts new file mode 100644 index 00000000..ca4daffa --- /dev/null +++ b/apps/web/src/telemetry/__tests__/telemetry-clients.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +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('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/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/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; +} 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/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..618b4ba0 --- /dev/null +++ b/packages/shared/src/types/telemetry.ts @@ -0,0 +1,7 @@ +export const FRONTEND_TELEMETRY_EVENTS = [ + 'interaction_after_idle', + 'page_view', + 'connection_switch', +] as const; + +export type FrontendTelemetryEvent = (typeof FRONTEND_TELEMETRY_EVENTS)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 316d3307..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 @@ -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 @@ -232,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 @@ -407,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 @@ -1404,6 +1410,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' @@ -2021,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==} @@ -2047,6 +2122,12 @@ packages: engines: {node: '>=18'} hasBin: true + '@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==} @@ -2106,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==} @@ -2808,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==} @@ -3657,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==} @@ -3915,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'} @@ -4239,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'} @@ -5708,6 +5831,21 @@ 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} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + 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'} @@ -5773,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'} @@ -5808,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==} @@ -6763,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==} @@ -7943,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 @@ -7956,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' @@ -7964,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 @@ -7997,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 @@ -8005,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 @@ -8017,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 @@ -8054,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': {} @@ -8312,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': @@ -8331,6 +8553,10 @@ snapshots: dependencies: playwright: 1.57.0 + '@posthog/core@1.24.6': {} + + '@posthog/types@1.364.6': {} + '@prisma/adapter-pg@7.5.0': dependencies: '@prisma/driver-adapter-utils': 7.5.0 @@ -8419,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)': @@ -9083,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': {} @@ -9960,6 +10212,8 @@ snapshots: cookiejar@2.1.4: {} + core-js@3.49.0: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -10208,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 @@ -10619,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 @@ -11557,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: @@ -11577,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 @@ -11587,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 @@ -11599,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 @@ -12200,6 +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 @@ -12280,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 @@ -12310,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: {} @@ -13282,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: {} 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,