322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { Response } from 'node-fetch';
|
|
import { TelemetryConfig, TelemetryService } from '../../src/services/telemetry/telemetry.service';
|
|
import logger from '../../src/utils/logger';
|
|
|
|
type FetchFunction = ConstructorParameters<typeof TelemetryService>[1];
|
|
|
|
const tempRoots: string[] = [];
|
|
const ciEnvKeys = ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'BUILDKITE', 'CIRCLECI', 'JENKINS_URL'];
|
|
let savedEnv: NodeJS.ProcessEnv;
|
|
|
|
beforeEach(() => {
|
|
savedEnv = { ...process.env };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = savedEnv;
|
|
vi.restoreAllMocks();
|
|
|
|
for (const tempRoot of tempRoots.splice(0)) {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeConfig(overrides: Partial<TelemetryConfig> = {}): TelemetryConfig {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'insforge-telemetry-'));
|
|
tempRoots.push(tempRoot);
|
|
|
|
return {
|
|
disabled: false,
|
|
endpoint: 'https://telemetry.test/v1/events',
|
|
posthogApiKey: 'phc_test',
|
|
installationIdPath: path.join(tempRoot, '.insforge-installation-id'),
|
|
heartbeatIntervalMs: 60_000,
|
|
requestTimeoutMs: 500,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeFetchMock(status = 204): FetchFunction {
|
|
return vi.fn(async () => new Response(null, { status })) as FetchFunction;
|
|
}
|
|
|
|
function clearRuntimeEnvironment(): void {
|
|
for (const key of ciEnvKeys) {
|
|
delete process.env[key];
|
|
}
|
|
|
|
delete process.env.NODE_ENV;
|
|
delete process.env.npm_lifecycle_event;
|
|
}
|
|
|
|
function getPostedBody(fetchMock: FetchFunction, callIndex = 0): Record<string, unknown> {
|
|
const call = vi.mocked(fetchMock).mock.calls[callIndex];
|
|
expect(call).toBeDefined();
|
|
const init = call?.[1];
|
|
expect(init).toBeDefined();
|
|
expect(typeof init).toBe('object');
|
|
|
|
const body = (init as { body?: unknown }).body;
|
|
expect(typeof body).toBe('string');
|
|
return JSON.parse(body as string) as Record<string, unknown>;
|
|
}
|
|
|
|
describe('TelemetryService', () => {
|
|
it('does not create an installation id or send events when disabled', async () => {
|
|
const config = makeConfig({ disabled: true });
|
|
const fetchMock = makeFetchMock();
|
|
|
|
await new TelemetryService(config, fetchMock).sendEvent('heartbeat');
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(fs.existsSync(config.installationIdPath)).toBe(false);
|
|
});
|
|
|
|
it('persists one anonymous installation id and reuses it across events', async () => {
|
|
const config = makeConfig();
|
|
const fetchMock = makeFetchMock();
|
|
const service = new TelemetryService(config, fetchMock);
|
|
|
|
await service.sendEvent('instance_started');
|
|
const installationId = fs.readFileSync(config.installationIdPath, 'utf8').trim();
|
|
const firstBody = getPostedBody(fetchMock);
|
|
|
|
await service.sendEvent('heartbeat');
|
|
const secondBody = getPostedBody(fetchMock, 1);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(firstBody.event).toBe('oss_instance_started');
|
|
expect(secondBody.event).toBe('oss_heartbeat');
|
|
expect(secondBody.distinct_id).toBe(installationId);
|
|
expect(installationId).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
);
|
|
});
|
|
|
|
it('reuses an installation id published by another process during the atomic create', async () => {
|
|
const config = makeConfig();
|
|
const fetchMock = makeFetchMock();
|
|
const racedInstallationId = '11111111-1111-4111-8111-111111111111';
|
|
const linkSync = fs.linkSync.bind(fs);
|
|
const linkSpy = vi.spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => {
|
|
if (newPath === config.installationIdPath) {
|
|
fs.writeFileSync(config.installationIdPath, racedInstallationId, { mode: 0o600 });
|
|
throw Object.assign(new Error('file exists'), { code: 'EEXIST' });
|
|
}
|
|
|
|
return linkSync(existingPath, newPath);
|
|
});
|
|
|
|
await new TelemetryService(config, fetchMock).sendEvent('instance_started');
|
|
|
|
const body = getPostedBody(fetchMock);
|
|
expect(linkSpy).toHaveBeenCalled();
|
|
expect(body.distinct_id).toBe(racedInstallationId);
|
|
expect(fs.readFileSync(config.installationIdPath, 'utf8')).toBe(racedInstallationId);
|
|
expect(fs.readdirSync(path.dirname(config.installationIdPath))).toEqual([
|
|
path.basename(config.installationIdPath),
|
|
]);
|
|
});
|
|
|
|
it('marks CI telemetry with the CI runtime environment', async () => {
|
|
process.env.CI = 'true';
|
|
const config = makeConfig();
|
|
const fetchMock = makeFetchMock();
|
|
|
|
await new TelemetryService(config, fetchMock).sendEvent('instance_started');
|
|
|
|
const body = getPostedBody(fetchMock);
|
|
expect(body.properties).toEqual(
|
|
expect.objectContaining({
|
|
runtime_environment: 'ci',
|
|
is_ci: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('marks local development telemetry with the development runtime environment', async () => {
|
|
clearRuntimeEnvironment();
|
|
process.env.npm_lifecycle_event = 'dev';
|
|
const config = makeConfig();
|
|
const fetchMock = makeFetchMock();
|
|
|
|
await new TelemetryService(config, fetchMock).sendEvent('instance_started');
|
|
|
|
const body = getPostedBody(fetchMock);
|
|
expect(body.properties).toEqual(
|
|
expect.objectContaining({
|
|
runtime_environment: 'development',
|
|
is_ci: false,
|
|
})
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'CI takes precedence over NODE_ENV',
|
|
setup: () => {
|
|
clearRuntimeEnvironment();
|
|
process.env.CI = 'true';
|
|
process.env.NODE_ENV = 'test';
|
|
},
|
|
expectedRuntimeEnvironment: 'ci',
|
|
expectedIsCi: true,
|
|
},
|
|
{
|
|
name: 'production NODE_ENV',
|
|
setup: () => {
|
|
clearRuntimeEnvironment();
|
|
process.env.NODE_ENV = 'production';
|
|
},
|
|
expectedRuntimeEnvironment: 'production',
|
|
expectedIsCi: false,
|
|
},
|
|
{
|
|
name: 'development NODE_ENV',
|
|
setup: () => {
|
|
clearRuntimeEnvironment();
|
|
process.env.NODE_ENV = 'development';
|
|
},
|
|
expectedRuntimeEnvironment: 'development',
|
|
expectedIsCi: false,
|
|
},
|
|
{
|
|
name: 'test NODE_ENV',
|
|
setup: () => {
|
|
clearRuntimeEnvironment();
|
|
process.env.NODE_ENV = 'test';
|
|
},
|
|
expectedRuntimeEnvironment: 'test',
|
|
expectedIsCi: false,
|
|
},
|
|
{
|
|
name: 'unknown runtime',
|
|
setup: clearRuntimeEnvironment,
|
|
expectedRuntimeEnvironment: 'unknown',
|
|
expectedIsCi: false,
|
|
},
|
|
])(
|
|
'marks telemetry with the $name runtime environment',
|
|
async ({ setup, expectedRuntimeEnvironment, expectedIsCi }) => {
|
|
setup();
|
|
const config = makeConfig();
|
|
const fetchMock = makeFetchMock();
|
|
|
|
await new TelemetryService(config, fetchMock).sendEvent('instance_started');
|
|
|
|
const body = getPostedBody(fetchMock);
|
|
expect(body.properties).toEqual(
|
|
expect.objectContaining({
|
|
runtime_environment: expectedRuntimeEnvironment,
|
|
is_ci: expectedIsCi,
|
|
})
|
|
);
|
|
}
|
|
);
|
|
|
|
it('starts once, schedules heartbeats, and stops the heartbeat timer', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const config = makeConfig({ heartbeatIntervalMs: 1_000 });
|
|
const fetchMock = makeFetchMock();
|
|
const service = new TelemetryService(config, fetchMock);
|
|
|
|
service.start();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(getPostedBody(fetchMock).event).toBe('oss_instance_started');
|
|
|
|
service.start();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(config.heartbeatIntervalMs);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(getPostedBody(fetchMock, 1).event).toBe('oss_heartbeat');
|
|
|
|
service.stop();
|
|
await vi.advanceTimersByTimeAsync(config.heartbeatIntervalMs);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('sends only coarse, non-sensitive runtime fields', async () => {
|
|
const config = makeConfig();
|
|
const fetchMock = makeFetchMock();
|
|
|
|
await new TelemetryService(config, fetchMock).sendEvent('heartbeat');
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://telemetry.test/v1/events',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'content-type': 'application/json',
|
|
}),
|
|
})
|
|
);
|
|
|
|
const body = getPostedBody(fetchMock);
|
|
expect(body).toEqual({
|
|
api_key: 'phc_test',
|
|
event: 'oss_heartbeat',
|
|
distinct_id: expect.any(String),
|
|
timestamp: expect.any(String),
|
|
properties: {
|
|
$process_person_profile: false,
|
|
installation_id: expect.any(String),
|
|
telemetry_source: 'insforge_oss',
|
|
telemetry_event_name: 'heartbeat',
|
|
version: expect.any(String),
|
|
hosting_mode: expect.stringMatching(/^(cloud|self-hosted)$/),
|
|
deployment_method: expect.any(String),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
node_version: process.version,
|
|
runtime_environment: expect.stringMatching(/^(production|development|test|ci|unknown)$/),
|
|
is_ci: expect.any(Boolean),
|
|
storage_backend: expect.stringMatching(/^(local|s3|s3-compatible)$/),
|
|
features: {
|
|
site_deployments_configured: expect.any(Boolean),
|
|
functions_configured: expect.any(Boolean),
|
|
compute_configured: expect.any(Boolean),
|
|
openrouter_configured: expect.any(Boolean),
|
|
},
|
|
},
|
|
});
|
|
expect(JSON.stringify(body)).not.toContain('JWT_SECRET');
|
|
expect(JSON.stringify(body)).not.toContain('ACCESS_API_KEY');
|
|
expect(JSON.stringify(body)).not.toContain('POSTGRES_PASSWORD');
|
|
});
|
|
|
|
it('logs and suppresses network errors', async () => {
|
|
const config = makeConfig();
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error('network down');
|
|
}) as FetchFunction;
|
|
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
|
|
|
|
await expect(new TelemetryService(config, fetchMock).sendEvent('heartbeat')).resolves.toBe(
|
|
undefined
|
|
);
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'InsForge telemetry skipped',
|
|
expect.objectContaining({ error: 'network down' })
|
|
);
|
|
});
|
|
});
|