MCP Terminal Server
by dillip285
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createInterface } from 'readline/promises';
import { v4 as uuidV4 } from 'uuid';
import { AnalyticsInfo } from '../types/analytics';
import { configstore, getUserSettings } from './configstore';
import { logger } from './logger';
import { toolsPackage } from './package';
// This code is largely adapted from
// https://github.com/firebase/firebase-tools/blob/master/src/track.ts
export const ANALYTICS_OPT_OUT_CONFIG_TAG = 'analyticsOptOut';
/**
* The track function accepts this abstract class, but callers should use
* one of the pre-defined events listed below this class. If you need to add a
* new event type, add it with the others below.
*/
abstract class GAEvent {
// The event name as it will appear in GA.
// This must be less than 40 characters
abstract name: string;
// Additional parameters.
// ABSOLUTELY NO FREE-FORM OR PII FIELDS
parameters?: Record<string, string | number | undefined>;
// Sticky parameters that should be maintained for the duration of the
// session.
stickyParameters?: Record<string, string | number | undefined>;
// Duration in milliseconds. Make sure this is always at least 1 so that the
// metrics appear in realtime view.
abstract duration: number;
}
// Add new events here; this way everything's centralized and auditable.
export class PageViewEvent extends GAEvent {
name = 'page_view';
duration = 1;
constructor(page_title: string) {
super();
this.parameters = { page_title };
}
}
export class FirstUsageEvent extends GAEvent {
name = 'first_visit';
duration = 1;
constructor() {
super();
}
}
export class ToolsRequestEvent extends GAEvent {
name = 'tools_request';
duration = 1;
constructor(route: string) {
super();
this.parameters = { route };
}
}
export class RunCommandEvent extends GAEvent {
name = 'run_command';
duration = 1; // Should we actually track command duration?
constructor(command: string) {
super();
this.stickyParameters = { command };
}
}
export class InitEvent extends GAEvent {
name = 'init';
duration = 1;
constructor(
platform: 'firebase' | 'googlecloud' | 'nodejs' | 'nextjs' | 'go'
) {
super();
this.parameters = { platform };
}
}
export class ConfigEvent extends GAEvent {
name = 'config_set';
duration = 1;
constructor(key: 'analyticsOptOut') {
super();
this.parameters = { key };
}
}
/**
* Main function for recording analytics. This is a no-op if analyitcs are
* disabled.
*/
export async function record(event: GAEvent): Promise<void> {
if (!isAnalyticsEnabled()) return;
await recordInternal(event, getSession());
}
/** Displays a notification that analytics are in use. */
export async function notifyAnalyticsIfFirstRun(): Promise<void> {
if (!isAnalyticsEnabled()) return;
if (configstore.get(NOTIFICATION_ACKED)) {
return;
}
console.log(ANALYTICS_NOTIFICATION);
await readline.question('Press "Enter" to continue');
configstore.set(NOTIFICATION_ACKED, true);
await record(new FirstUsageEvent());
}
/** Gets session information for the UI. */
export function getAnalyticsSettings(): AnalyticsInfo {
if (!isAnalyticsEnabled()) {
return { enabled: false };
}
const session = getSession();
return {
enabled: true,
property: GA_INFO.property,
measurementId: GA_INFO.measurementId,
apiSecret: GA_INFO.apiSecret,
clientId: session.clientId,
sessionId: session.sessionId,
debug: {
debugMode: isDebugMode(),
validateOnly: isValidateOnly(),
},
};
}
// ===============================================================
// Start internal implementation
const ANALYTICS_NOTIFICATION =
'Genkit CLI and Developer UI use cookies and ' +
'similar technologies from Google\nto deliver and enhance the quality of its ' +
'services and to analyze usage.\n' +
'Learn more at https://policies.google.com/technologies/cookies';
const NOTIFICATION_ACKED = 'analytics_notification';
const CONFIGSTORE_CLIENT_KEY = 'genkit-tools-ga-id';
const GA_INFO = {
property: 'genkit-tools',
measurementId: 'G-2K1MPK763J',
apiSecret: 'UccV7rIoTF6II6E9zYX5Ow',
};
const GA_USER_PROPS = {
node_platform: {
value: process.platform,
},
node_version: {
value: process.version,
},
tools_version: {
value: toolsPackage.version,
},
};
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
interface AnalyticsSession {
clientId: string;
// https://support.google.com/analytics/answer/9191807
// We treat each CLI invocation as a different session, including any CLI
// events.
sessionId: string;
totalEngagementSeconds: number;
stickyParameters: Record<string, string | number | undefined>;
}
// Whether the events sent should be tagged so that they are shown in GA Debug
// View in real time (for Googler to debug) and excluded from reports. To
// enable, set the env var GENKIT_GA_DEBUG.
function isDebugMode(): boolean {
return !!process.env['GENKIT_GA_DEBUG'];
}
// Whether to validate events format instead of collecting them. Should only
// be used to debug the Firebase CLI / Emulator UI itself regarding issues
// with Analytics. To enable, set the env var GENKIT_GA_VALIDATE.
// In the CLI, this is implemented by sending events to the GA4 measurement
// validation API (which does not persist events) and printing the response.
function isValidateOnly(): boolean {
return !!process.env['GENKIT_GA_VALIDATE'];
}
// For now, this is default false unless GENKIT_GA_DEBUG or GENKIT_GA_VALIDATE
// are set. Once we have opt-out and we're ready for public preview this will
// get updated.
function isAnalyticsEnabled(): boolean {
return (
!process.argv.includes('--non-interactive') &&
!getUserSettings()[ANALYTICS_OPT_OUT_CONFIG_TAG]
);
}
async function recordInternal(
event: GAEvent,
session: AnalyticsSession
): Promise<void> {
Object.assign(session.stickyParameters, event.stickyParameters);
const joinedParams = { ...session.stickyParameters, ...event.parameters };
const validate = isValidateOnly();
const search = `?api_secret=${GA_INFO.apiSecret}&measurement_id=${GA_INFO.measurementId}`;
const validatePath = isValidateOnly() ? 'debug/' : '';
const url = `https://www.google-analytics.com/${validatePath}mp/collect${search}`;
const body = {
// Get timestamp in millis and append '000' to get micros as string.
// Not using multiplication due to JS number precision limit.
timestamp_micros: `${Date.now()}000`,
client_id: session.clientId,
user_properties: {
...GA_USER_PROPS,
},
validationBehavior: validate ? 'ENFORCE_RECOMMENDATIONS' : undefined,
events: [
{
name: event.name,
params: {
session_id: session.sessionId,
// engagement_time_msec and session_id must be set for the activity
// to display in standard reports like Realtime.
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#optional_parameters_for_reports
// https://support.google.com/analytics/answer/11109416?hl=en
// Additional engagement time since last event, in microseconds.
engagement_time_msec: event.duration
.toFixed(3)
.replace('.', '')
.replace(/^0+/, ''), // trim leading zeros
// https://support.google.com/analytics/answer/7201382?hl=en
// To turn debug mode off, `debug_mode` must be left out not `false`.
debug_mode: isDebugMode() ? true : undefined,
...joinedParams,
},
},
],
};
if (validate) {
logger.info(
`Sending Analytics for event ${event.name}`,
joinedParams,
body
);
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify(body),
});
if (validate) {
// If the validation endpoint is used, response may contain errors.
if (!response.ok) {
logger.warn(`Analytics validation HTTP error: ${response.status}`);
}
const respBody = await response.text;
logger.info(`Analytics validation result: ${respBody}`);
}
// response.ok / response.status intentionally ignored, see comment below.
} catch (e: unknown) {
if (validate) {
throw e;
}
// Otherwise, we will ignore the status / error for these reasons:
// * the endpoint always return 2xx even if request is malformed
// * non-2xx requests should _not_ be retried according to documentation
// * analytics is non-critical and should not fail other operations.
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?client_type=gtag#response_codes
return;
}
}
let currentSession: AnalyticsSession | undefined = undefined;
function getSession(): AnalyticsSession {
if (currentSession) {
return currentSession;
}
// ClientID is sticky
let clientId: string | undefined = configstore.get(CONFIGSTORE_CLIENT_KEY);
if (!clientId) {
clientId = uuidV4();
configstore.set(CONFIGSTORE_CLIENT_KEY, clientId);
}
currentSession = {
clientId,
// This must be an int64 string, but only ~50 bits are generated here
// for simplicity. (AFAICT, they just need to be unique per clientId,
// instead of globally. Revisit if that is not the case.)
// https://help.analyticsedge.com/article/misunderstood-metrics-sessions-in-google-analytics-4/#:~:text=The%20Session%20ID%20Is%20Not%20Unique
sessionId: (Math.random() * Number.MAX_SAFE_INTEGER).toFixed(0),
totalEngagementSeconds: 0,
stickyParameters: {},
};
return currentSession;
}