MCP Terminal Server

/** * 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; }