Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
95104ea
feature: telemetry adapter abstraction with TelemetryPort, NoopAdapte…
jamby77 Apr 2, 2026
e1364a4
fix: restore BETTERDB_TELEMETRY transform, use z.url() for POSTHOG_HO…
jamby77 Apr 2, 2026
685fcf8
chore: rename NoopTelemetryAdapter to NoopTelemetryClientAdapter for …
jamby77 Apr 2, 2026
52d7eb2
fix: clarify factory test name to match actual assertion
jamby77 Apr 2, 2026
4f95f85
feature: add stub Http and Posthog adapters, wire factory to return c…
jamby77 Apr 2, 2026
a95b12b
chore: extract shared setup into beforeEach in integration tests
jamby77 Apr 2, 2026
2a772eb
fix: add missing await to ConfigModule initialization in telemetry test
jamby77 Apr 2, 2026
d85f42d
fix: address code review findings from PR bot
jamby77 Apr 2, 2026
80e806b
chore: add readonly to constructor-only fields in UsageTelemetryService
jamby77 Apr 2, 2026
b5388fe
fix: restore try/catch in sendEvent to keep telemetry fire-and-forget
jamby77 Apr 2, 2026
981a658
feature: PosthogTelemetryClientAdapter with posthog-node (#83)
jamby77 Apr 2, 2026
57e4b45
feature: add GET /telemetry/config endpoint for frontend runtime conf…
jamby77 Apr 2, 2026
db15946
fix: handle both boolean and string 'false' for BETTERDB_TELEMETRY in…
jamby77 Apr 2, 2026
8301dba
refactor: improve readability and type clarity in TelemetryController
jamby77 Apr 2, 2026
b2df124
feature: extract telemetry event types to shared, add DTO with class-…
jamby77 Apr 2, 2026
b99ccf1
fix: remove posthog API key and host from telemetry config endpoint
jamby77 Apr 2, 2026
0b027c2
fix: add default case to event switch to throw on unhandled event types
jamby77 Apr 2, 2026
3d387ea
feature: frontend TelemetryConfigProvider + ApiTelemetryClient + hook…
jamby77 Apr 2, 2026
45a936b
feature: frontend PosthogTelemetryClient with posthog-js (#88)
jamby77 Apr 2, 2026
0409c23
fix: use module-level singleton for telemetry client to prevent per-c…
jamby77 Apr 2, 2026
313a0ba
chore: remove unused backend telemetry type exports from shared package
jamby77 Apr 2, 2026
b7762bd
chore: remove NoopTelemetryClient from frontend, use ApiTelemetryClie…
jamby77 Apr 2, 2026
4e8dec5
fix: call identify with instanceId when creating PostHog frontend client
jamby77 Apr 2, 2026
893ab63
feature: route LicenseService heartbeat through TelemetryPort adapter…
jamby77 Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/common/interfaces/telemetry-port.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface TelemetryEvent {
distinctId: string;
event: string;
properties?: Record<string, unknown>;
}

export interface TelemetryPort {
capture(event: TelemetryEvent): void;
identify(distinctId: string, properties: Record<string, unknown>): void;
shutdown(): Promise<void>;
}
187 changes: 112 additions & 75 deletions apps/api/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof envSchema>;

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading