Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 29 additions & 1 deletion packages/cli/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Ensure } from './services/ensure.service.js';
import { create } from './services/function-create.service.js';
import { inferIntegrationsFromConnectionId } from './services/interactive.service.js';
import { pullFromCatalog, pullFunction } from './services/pull.service.js';
import { trackCliEvent } from './services/telemetry.service.js';
import { generateTests } from './services/test.service.js';
import verificationService from './services/verification.service.js';
import { getNangoRootPath, isCI, printDebug, upgradeAction } from './utils.js';
Expand All @@ -34,7 +35,21 @@ import { initZero } from './zeroYaml/init.js';
import { ReadableError } from './zeroYaml/utils.js';

import type { DeployOptions, GlobalOptions } from './types.js';
import type { NangoYamlParsed, ScriptTypeLiteral } from '@nangohq/types';
import type { CliTelemetryEvent, NangoYamlParsed, ScriptTypeLiteral } from '@nangohq/types';

const commandTelemetryEvents: Record<string, CliTelemetryEvent> = {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
init: 'cli:init',
create: 'cli:create',
compile: 'cli:compile',
dev: 'cli:dev',
dryrun: 'cli:dryrun',
'generate:docs': 'cli:generate:docs',
'generate:tests': 'cli:generate:tests',
clone: 'cli:clone',
'migrate-to-zero-yaml': 'cli:migrate_to_zero_yaml',
deploy: 'cli:deploy',
pull: 'cli:pull'
};

class NangoCommand extends Command {
override createCommand(name: string) {
Expand All @@ -46,6 +61,7 @@ class NangoCommand extends Command {
// Passing --no-interactive will set it to false.
cmd.option('--no-interactive', 'Disable interactive prompts for missing arguments.');
cmd.option('--no-dependency-update', 'Skip automatic dependency updates and package installation.');
cmd.option('--no-telemetry', 'Disable anonymous CLI usage telemetry.');

cmd.hook('preAction', async function (this: Command, actionCommand: Command) {
const opts = actionCommand.opts<GlobalOptions>();
Expand Down Expand Up @@ -77,6 +93,18 @@ class NangoCommand extends Command {
}
printDebug(`Dependency update: ${opts.dependencyUpdate ? 'enabled' : 'disabled'}`, opts.debug);

if (opts.telemetry === false) {
process.env['NANGO_CLI_TELEMETRY'] = 'false';
}
if (opts.debug) {
process.env['NANGO_CLI_DEBUG'] = 'true';
}

const telemetryEvent = commandTelemetryEvents[actionCommand.name()];
if (telemetryEvent) {
trackCliEvent(telemetryEvent);
}

if (opts.debug && fs.existsSync('.env')) {
printDebug('.env file detected and loaded', opts.debug);
}
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/lib/services/telemetry.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { getDeviceId } from '../state.js';
import { getCliHeaders, isCliDebugEnabled, isTelemetryDisabled, printDebug, resolveHostport } from '../utils.js';

import type { CliTelemetryEvent, PostCliTelemetry } from '@nangohq/types';

const TELEMETRY_TIMEOUT_MS = 2000;

export { isTelemetryDisabled };

/**
* Send an anonymous CLI usage event. Fire-and-forget
*/
export function trackCliEvent(event: CliTelemetryEvent): void {
if (isTelemetryDisabled()) {
return;
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);

const { deviceId, ephemeral } = getDeviceId();
const body: PostCliTelemetry['Body'] = { deviceId, event, ephemeral };
void fetch(new URL('/cli/telemetry', resolveHostport()), {
method: 'POST',
body: JSON.stringify(body),
headers: new Headers({ ...getCliHeaders(), 'content-type': 'application/json' }),
signal: controller.signal
})
.catch((err) => {
printDebug(`Telemetry delivery failed: ${err instanceof Error ? err.message : String(err)}`, isCliDebugEnabled());
})
.finally(() => {
clearTimeout(timeout);
});
}
20 changes: 20 additions & 0 deletions packages/cli/lib/state.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
import { randomUUID } from 'crypto';

import Conf from 'conf';

const schema = {
lastIgnoreUpgrade: {
type: 'number'
},
deviceId: {
type: 'string'
}
};
export const state = new Conf({ projectName: 'nango', schema });

export function getDeviceId(): { deviceId: string; ephemeral: boolean } {
try {
let deviceId = state.get('deviceId') as string | undefined;
if (!deviceId) {
deviceId = randomUUID();
state.set('deviceId', deviceId);
}
return { deviceId, ephemeral: false };
} catch {
// ephemeral means the id couldn't be persisted (e.g. read-only FS), so it's a throwaway
// that can't correlate across runs. Callers should flag it and skip device correlation.
return { deviceId: randomUUID(), ephemeral: true };
}
}
1 change: 1 addition & 0 deletions packages/cli/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface GlobalOptions {
debug: boolean;
interactive: boolean;
dependencyUpdate: boolean;
telemetry?: boolean;
}

export type ENV = 'local' | 'cloud';
Expand Down
35 changes: 32 additions & 3 deletions packages/cli/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import semver from 'semver';
import { serializeError } from 'serialize-error';

import { cloudHost, localhostUrl } from './constants.js';
import { state } from './state.js';
import { getDeviceId, state } from './state.js';
import { Err, Ok } from './utils/result.js';
import { NANGO_VERSION } from './version.js';

Expand Down Expand Up @@ -273,10 +273,25 @@ export function enrichHeaders(headers: Record<string, string | number | boolean>
return headers;
}

export function isTelemetryDisabled(): boolean {
return process.env['NANGO_CLI_TELEMETRY'] === 'false';
}

export function isCliDebugEnabled(): boolean {
return process.env['NANGO_CLI_DEBUG'] === 'true';
}

const defaultHttpsAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false });
export const http = axios.create({
httpsAgent: defaultHttpsAgent,
headers: { 'User-Agent': getUserAgent() }
httpsAgent: defaultHttpsAgent
});

http.interceptors.request.use((config) => {
const cliHeaders = getCliHeaders();
for (const [key, value] of Object.entries(cliHeaders)) {
config.headers.set(key, value);
}
return config;
});

export function getUserAgent(): string {
Expand All @@ -288,6 +303,20 @@ export function getUserAgent(): string {
return `nango-cli/${clientVersion} (${osName}/${osVersion}; node.js/${nodeVersion})`;
}

export function getCliHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': getUserAgent()
};
if (!isTelemetryDisabled()) {
const { deviceId, ephemeral } = getDeviceId();
// Only correlate persisted ids; a throwaway id would just create junk aliases server-side.
if (!ephemeral) {
headers['Nango-CLI-Device-Id'] = deviceId;
}
}
return headers;
}

export function getNangoRootPath(debug = false): string {
const packagePath = getPackagePath(debug);
const rootPath = path.resolve(packagePath, '..');
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/lib/zeroYaml/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import chalk from 'chalk';
import columnify from 'columnify';
import promptly from 'promptly';

import { isCI, parseSecretKey, printDebug, resolveHostport } from '../utils.js';
import { getCliHeaders, isCI, parseSecretKey, printDebug, resolveHostport } from '../utils.js';
import { Err, Ok } from '../utils/result.js';
import { Spinner } from '../utils/spinner.js';
import { NANGO_VERSION } from '../version.js';
Expand Down Expand Up @@ -378,6 +378,7 @@ async function postConfirmation({
method: 'POST',
body: JSON.stringify(body),
headers: new Headers({
...getCliHeaders(),
authorization: `Bearer ${process.env['NANGO_SECRET_KEY']}`,
'content-type': 'application/json'
})
Expand Down Expand Up @@ -410,6 +411,7 @@ async function postDeploy({ hostport, body }: { hostport: string; body: PostDepl
method: 'POST',
body: JSON.stringify(body),
headers: new Headers({
...getCliHeaders(),
authorization: `Bearer ${process.env['NANGO_SECRET_KEY']}`,
'content-type': 'application/json'
})
Expand Down
35 changes: 35 additions & 0 deletions packages/server/lib/controllers/cli/postTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as z from 'zod';

import { productTracking } from '@nangohq/shared';
import { cliTelemetryEvents, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';

import { asyncWrapper } from '../../utils/asyncWrapper.js';

import type { PostCliTelemetry } from '@nangohq/types';

const bodySchema = z
.object({
deviceId: z.string().uuid(),
event: z.enum(cliTelemetryEvents),
ephemeral: z.boolean().optional()
})
.strict();

export const postCliTelemetry = asyncWrapper<PostCliTelemetry>((req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

const val = bodySchema.safeParse(req.body);
if (!val.success) {
res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(val.error) } });
return;
}

const { deviceId, event, ephemeral } = val.data;
productTracking.trackAnonymous({ name: event, distinctId: deviceId, ...(ephemeral ? { eventProperties: { 'device-id-ephemeral': true } } : {}) });

res.status(204).send();
});
16 changes: 15 additions & 1 deletion packages/server/lib/controllers/sync/deploy/postDeploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logContextGetter } from '@nangohq/logs';
import { cleanIncomingFlow, deploy, errorManager, getAndReconcileDifferences, NangoError, productTracking, startTrial } from '@nangohq/shared';
import { getLogger, requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';

import { getCliContext } from '../../../middleware/cliVersionCheck.js';
import { startFunctionDeletion } from '../../../tasks/startFunctionDeletion.js';
import { asyncWrapper } from '../../../utils/asyncWrapper.js';
import { getOrchestrator } from '../../../utils/utils.js';
Expand Down Expand Up @@ -31,6 +32,17 @@ export const postDeploy = asyncWrapper<PostDeploy>(async (req, res) => {
const body: PostDeploy['Body'] = val.data;
const { environment, account, plan } = res.locals;

const { cliVersion, deviceId } = getCliContext(req);
const trackingProperties: Record<string, string | number | boolean> = {
'cli-version': cliVersion || 'unknown',
source: body.source ?? 'repo',
'flow-count': body.flowConfigs.length
};

if (deviceId) {
productTracking.alias({ deviceId, team: account });
}

// Prevent concurrent deploys per environment, fail immediately if another deploy is in flight.
const locking = await getLocking();
const ttlMs = process.env['DEPLOY_LOCK_TTL_MS'] ? parseInt(process.env['DEPLOY_LOCK_TTL_MS']) : 10 * 60 * 1000; // max expected deploy duration
Expand Down Expand Up @@ -75,6 +87,7 @@ export const postDeploy = asyncWrapper<PostDeploy>(async (req, res) => {
}

if (!success || !syncConfigDeployResult) {
productTracking.track({ name: 'deploy:error', team: account, eventProperties: { ...trackingProperties, 'error-code': error?.type || 'unknown' } });
errorManager.errResFromNangoErr(res, error);
return;
}
Expand All @@ -93,6 +106,7 @@ export const postDeploy = asyncWrapper<PostDeploy>(async (req, res) => {
onFunctionDeleted: ({ syncConfigId, models }) => startFunctionDeletion({ syncConfigId, environmentId: environment.id, models })
});
if (!success) {
productTracking.track({ name: 'deploy:error', team: account, eventProperties: { ...trackingProperties, 'error-code': 'reconcile_failed' } });
res.status(500).send({
error: {
code: 'server_error',
Expand All @@ -103,7 +117,7 @@ export const postDeploy = asyncWrapper<PostDeploy>(async (req, res) => {
}
}

productTracking.track({ name: 'deploy:success', team: account });
productTracking.track({ name: 'deploy:success', team: account, eventProperties: trackingProperties });

res.send(syncConfigDeployResult.result);
} finally {
Expand Down
17 changes: 17 additions & 0 deletions packages/server/lib/middleware/cliVersionCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ import type { NextFunction, Request, Response } from 'express';
const logger = getLogger('CliVersionCheck');

const VERSION_REGEX = /nango-cli\/([0-9.]+)/;

export function getCliContext(req: Request): { cliVersion?: string; deviceId?: string } {
const context: { cliVersion?: string; deviceId?: string } = {};

const match = req.headers['user-agent']?.match(VERSION_REGEX);
if (match?.[1]) {
context.cliVersion = match[1];
}

const deviceId = req.headers['nango-cli-device-id'];
if (typeof deviceId === 'string' && deviceId) {
context.deviceId = deviceId;
}

return context;
}

export function cliMinVersion(minVersion: string) {
return (req: Request, res: Response, next: NextFunction) => {
const userAgent = req.headers['user-agent'];
Expand Down
5 changes: 5 additions & 0 deletions packages/server/lib/routes.public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { postPublicSignatureAuthorization } from './controllers/auth/postSignatu
import { postPublicTbaAuthorization } from './controllers/auth/postTba.js';
import { postPublicTwoStepAuthorization } from './controllers/auth/postTwoStep.js';
import { postPublicUnauthenticated } from './controllers/auth/postUnauthenticated.js';
import { postCliTelemetry } from './controllers/cli/postTelemetry.js';
import configController from './controllers/config.controller.js';
import { deleteConnectSession } from './controllers/connect/deleteSession.js';
import { getConnectSession } from './controllers/connect/getSession.js';
Expand Down Expand Up @@ -269,6 +270,10 @@ publicAPI.route('/sync/deploy').post(apiAuth, withScope('environment:deploy'), c
publicAPI.route('/sync/deploy/confirmation').post(apiAuth, withScope('environment:deploy'), cliMinVersion('0.39.25'), postDeployConfirmation);
publicAPI.route('/sync/deploy/internal').post(apiAuth, withScope('environment:deploy'), postDeployInternal);

// CLI
publicAPI.use('/cli', jsonContentTypeMiddleware);
publicAPI.route('/cli/telemetry').post(rateLimiterMiddleware, postCliTelemetry);

// Syncs
publicAPI.route('/sync/update-connection-frequency').put(apiAuth, withScope('environment:syncs:update'), putSyncConnectionFrequency);

Expand Down
Loading
Loading