diff --git a/package-lock.json b/package-lock.json index 4beefbde..0bcfbfaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "1.13.8", + "version": "1.13.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "1.13.8", + "version": "1.13.9", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", @@ -9626,6 +9626,21 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 1d4ee8a4..29f05358 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "An MCP server for Tableau, providing a suite of tools that will make it easier for developers to build AI applications that integrate with Tableau.", - "version": "1.13.8", + "version": "1.13.9", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" @@ -22,8 +22,8 @@ "clean": "npx rimraf ./build", ":build": "esbuild src/index.ts --bundle --platform=node --format=cjs --minify --outfile=build/index.js --sourcemap --log-override:empty-import-meta=silent", ":build:dev": "esbuild src/index.ts --bundle --packages=external --platform=node --format=cjs --outfile=build/index.js --sourcemap", - "build": "run-s clean :build exec-perms", - "build:dev": "run-s clean :build:dev exec-perms", + "build": "run-s clean :build :build:tracing exec-perms", + "build:dev": "run-s clean :build:dev :build:tracing exec-perms", "build:watch": "npm run :build:dev -- --watch", "build:docker": "docker build -t tableau-mcp .", ":build:mcpb": "npx -y @anthropic-ai/mcpb pack . tableau-mcp.mcpb", @@ -31,6 +31,8 @@ "build:manifest": "node build/scripts/createClaudeMcpBundleManifest.mjs", "build:manifest:script": "esbuild src/scripts/createClaudeMcpBundleManifest.ts --bundle --platform=node --format=esm --outdir=build/scripts --sourcemap=inline --out-extension:.js=.mjs", "start:http": "node build/index.js", + "start:http:apm": "node -r ./build/telemetry/tracing.js build/index.js", + ":build:tracing": "esbuild src/telemetry/tracing.ts --bundle --packages=external --platform=node --format=cjs --outfile=build/telemetry/tracing.js", "start:http:docker": "docker run -p 3927:3927 -i --rm --env-file env.list tableau-mcp", "lint": "npm exec eslint", "inspect": "npx @modelcontextprotocol/inspector --config config.json --server tableau", diff --git a/src/config.ts b/src/config.ts index 10aa0b06..2f82327a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { CorsOptions } from 'cors'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { TelemetryConfig } from './telemetry/types.js'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; import { getDirname } from './utils/getDirname.js'; @@ -76,6 +77,7 @@ export class Config { clientIdSecretPairs: Record | null; dnsServers: string[]; }; + telemetry: TelemetryConfig; constructor() { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env); @@ -129,6 +131,9 @@ export class Config { OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: authzCodeTimeoutMs, OAUTH_ACCESS_TOKEN_TIMEOUT_MS: accessTokenTimeoutMs, OAUTH_REFRESH_TOKEN_TIMEOUT_MS: refreshTokenTimeoutMs, + TELEMETRY_ENABLED: telemetryEnabled, + TELEMETRY_PROVIDER: telemetryProvider, + TELEMETRY_PROVIDER_CONFIG: telemetryProviderConfig, } = cleansedVars; let jwtUsername = ''; @@ -223,6 +228,12 @@ export class Config { : null, }; + this.telemetry = { + enabled: telemetryEnabled === 'true', + provider: (telemetryProvider as 'noop' | 'moncloud' | 'custom') || 'noop', + providerConfig: telemetryProviderConfig ? JSON.parse(telemetryProviderConfig) : undefined, + }; + this.auth = isAuthType(auth) ? auth : this.oauth.enabled ? 'oauth' : 'pat'; this.transport = isTransport(transport) ? transport : this.oauth.enabled ? 'http' : 'stdio'; diff --git a/src/telemetry/init.test.ts b/src/telemetry/init.test.ts new file mode 100644 index 00000000..21417a19 --- /dev/null +++ b/src/telemetry/init.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +// Mock the config module +vi.mock('../config.js', () => ({ + getConfig: vi.fn(), +})); + +// Mock the provider modules +vi.mock('./moncloud.js', () => ({ + MonCloudTelemetryProvider: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + addAttributes: vi.fn(), + })), +})); + +vi.mock('./noop.js', () => ({ + NoOpTelemetryProvider: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + addAttributes: vi.fn(), + })), +})); + +import { getConfig } from '../config.js'; +import { initializeTelemetry } from './init.js'; +import { MonCloudTelemetryProvider } from './moncloud.js'; +import { NoOpTelemetryProvider } from './noop.js'; +import { TelemetryConfig } from './types.js'; + +describe('initializeTelemetry', () => { + const mockGetConfig = getConfig as Mock; + + const defaultTelemetryConfig: TelemetryConfig = { + enabled: true, + provider: 'noop', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // MonCloud tests + it('returns MonCloudTelemetryProvider when provider is "moncloud"', () => { + mockGetConfig.mockReturnValue({ + telemetry: { ...defaultTelemetryConfig, provider: 'moncloud' }, + }); + + initializeTelemetry(); + + expect(MonCloudTelemetryProvider).toHaveBeenCalled(); + }); + + // NoOp tests + it('returns NoOpTelemetryProvider when telemetry is disabled', () => { + mockGetConfig.mockReturnValue({ + telemetry: { ...defaultTelemetryConfig, enabled: false }, + }); + + const provider = initializeTelemetry(); + + expect(NoOpTelemetryProvider).toHaveBeenCalled(); + expect(provider.initialize).toBeDefined(); + expect(provider.addAttributes).toBeDefined(); + }); + + it('returns NoOpTelemetryProvider when provider is "noop"', () => { + mockGetConfig.mockReturnValue({ + telemetry: { ...defaultTelemetryConfig, provider: 'noop' }, + }); + + initializeTelemetry(); + + expect(NoOpTelemetryProvider).toHaveBeenCalled(); + }); + + it('returns NoOpTelemetryProvider for unknown provider with warning', () => { + mockGetConfig.mockReturnValue({ + telemetry: { ...defaultTelemetryConfig, provider: 'unknown-provider' }, + }); + + initializeTelemetry(); + + expect(NoOpTelemetryProvider).toHaveBeenCalled(); + }); + + it('falls back to NoOpTelemetryProvider on initialization error', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Make MonCloudTelemetryProvider throw during initialization + (MonCloudTelemetryProvider as Mock).mockImplementationOnce(() => ({ + initialize: vi.fn().mockImplementation(() => { + throw new Error('Init failed'); + }), + addAttributes: vi.fn(), + })); + + mockGetConfig.mockReturnValue({ + telemetry: { ...defaultTelemetryConfig, provider: 'moncloud' }, + }); + + const provider = initializeTelemetry(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to initialize telemetry provider:', + expect.any(Error), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith('Falling back to NoOp telemetry provider'); + expect(provider).toBeDefined(); + + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); +}); diff --git a/src/telemetry/init.ts b/src/telemetry/init.ts new file mode 100644 index 00000000..cfc725d0 --- /dev/null +++ b/src/telemetry/init.ts @@ -0,0 +1,156 @@ +/** + * Telemetry initialization and provider factory + */ + +import { resolve } from 'path'; + +import { getConfig } from '../config.js'; +import { MonCloudTelemetryProvider } from './moncloud.js'; +import { NoOpTelemetryProvider } from './noop.js'; +import { TelemetryProvider } from './types.js'; + +/** + * Initialize the telemetry provider based on configuration. + * + * This function should be called early in application startup, before any + * HTTP requests or other instrumented operations occur. + * + * @returns A configured telemetry provider + * + * @example + * ```typescript + * function main() { + * // Initialize telemetry first + * const telemetry = initializeTelemetry(); + * + * // Add global attributes + * telemetry.addAttributes({ + * 'tableau.server': config.server, + * 'mcp.version': '1.0.0', + * }); + * + * // Start application... + * } + * ``` + */ +export function initializeTelemetry(): TelemetryProvider { + const config = getConfig(); + + // If telemetry is disabled, use NoOp provider + if (!config.telemetry.enabled) { + const provider = new NoOpTelemetryProvider(); + provider.initialize(); + return provider; + } + + let provider: TelemetryProvider; + + try { + // Select provider based on configuration + switch (config.telemetry.provider) { + case 'moncloud': + provider = new MonCloudTelemetryProvider(); + break; + + case 'custom': + // Load custom provider from user's filesystem + provider = loadCustomProvider(config.telemetry.providerConfig); + break; + + case 'noop': + default: + if (config.telemetry.provider !== 'noop') { + console.warn( + `Unknown telemetry provider: ${config.telemetry.provider}. Using NoOp provider.`, + ); + } + provider = new NoOpTelemetryProvider(); + } + + // Initialize the provider + provider.initialize(); + return provider; + } catch (error) { + console.error('Failed to initialize telemetry provider:', error); + console.warn('Falling back to NoOp telemetry provider'); + + // Fallback to NoOp on error - telemetry failures shouldn't break the application + const fallbackProvider = new NoOpTelemetryProvider(); + fallbackProvider.initialize(); + return fallbackProvider; + } +} + +/** + * Load a custom telemetry provider from user's filesystem or npm package. + * + * The custom provider module should export a default class that implements TelemetryProvider. + * + * @param config - Provider configuration containing the module path + * @returns A configured custom telemetry provider + * + * @example Custom provider from file + * ```bash + * TELEMETRY_PROVIDER=custom + * TELEMETRY_PROVIDER_CONFIG='{"module":"./my-telemetry.js"}' + * ``` + * + * @example Custom provider from npm package + * ```bash + * TELEMETRY_PROVIDER=custom + * TELEMETRY_PROVIDER_CONFIG='{"module":"my-company-telemetry"}' + * ``` + */ +function loadCustomProvider(config?: Record): TelemetryProvider { + if (!config?.module) { + throw new Error( + 'Custom telemetry provider requires "module" in providerConfig. ' + + 'Example: TELEMETRY_PROVIDER_CONFIG=\'{"module":"./my-telemetry.js"}\'', + ); + } + + const modulePath = config.module as string; + + // Determine if it's a file path or npm package name + let resolvedPath: string; + + if (modulePath.startsWith('.') || modulePath.startsWith('/')) { + // File path - resolve relative to process working directory (user's project root) + resolvedPath = resolve(process.cwd(), modulePath); + } else { + // npm package name - require as-is + resolvedPath = modulePath; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Sync load for preload script + const module = require(resolvedPath); + + // Look for default export or named export "TelemetryProvider" + const ProviderClass = module.default || module.TelemetryProvider; + + if (!ProviderClass) { + throw new Error( + `Module ${modulePath} must export a default class or named export "TelemetryProvider" ` + + 'that implements the TelemetryProvider interface', + ); + } + + // Instantiate the provider with the full config + return new ProviderClass(config); + } catch (error) { + // Provide helpful error message with common issues + let errorMessage = `Failed to load custom telemetry provider from "${modulePath}". `; + + if ((error as any).code === 'MODULE_NOT_FOUND') { + errorMessage += + 'Module not found. ' + + 'If using a file path, ensure the file exists and the path is correct. ' + + 'If using an npm package, ensure it is installed.'; + } else { + errorMessage += `Error: ${error}`; + } + + throw new Error(errorMessage); + } +} diff --git a/src/telemetry/moncloud.ts b/src/telemetry/moncloud.ts new file mode 100644 index 00000000..4ef39ffa --- /dev/null +++ b/src/telemetry/moncloud.ts @@ -0,0 +1,49 @@ +/** + * MonCloud telemetry provider for Salesforce's internal monitoring platform. + * + * Configuration is done via environment variables: + * - SFDC_SERVICE_NAME: Service name + * - SFDC_SUBSERVICE_NAME: Subservice name + * - SFDC_ENV: Environment (e.g., 'prod', 'test') + * - SFDC_SCOPE1, SFDC_SCOPE2, SFDC_SCOPE3: Scope identifiers + * - SFDC_METRICS_ENDPOINT: Metrics endpoint URL + * - SFDC_TRACES_ENDPOINT: Traces endpoint URL + * - SFDC_EVENTS_ENDPOINT: Events endpoint URL + * + * See: https://git.soma.salesforce.com/monitoring/salesforce-apmagent + */ + +import { TelemetryAttributes, TelemetryProvider } from './types.js'; + +export class MonCloudTelemetryProvider implements TelemetryProvider { + private trace: any; + + initialize(): void { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Sync load for preload script + const { Apm } = require('@salesforce/apmagent'); + const apm = new Apm(); + apm.start(); + } catch (error) { + console.error('Failed to initialize MonCloud telemetry:', error); + throw new Error( + 'MonCloud APM agent initialization failed. ' + + 'Ensure @salesforce/apmagent is installed and configured correctly. ' + + `Error: ${error}`, + ); + } + } + + addAttributes(attributes: TelemetryAttributes): void { + // Add custom attributes to the current auto-generated span + try { + const span = this.trace?.getActiveSpan(); + if (span) { + span.setAttributes(attributes); + } + } catch (error) { + // Log but don't throw - telemetry failures shouldn't break the application + console.warn('Failed to add telemetry attributes:', error); + } + } +} diff --git a/src/telemetry/noop.ts b/src/telemetry/noop.ts new file mode 100644 index 00000000..4e37dd86 --- /dev/null +++ b/src/telemetry/noop.ts @@ -0,0 +1,19 @@ +/** + * NoOp telemetry provider - does nothing. + * This is the default provider when telemetry is disabled. + * + * Zero overhead implementation that can be safely used in production + * when telemetry is not needed. + */ + +import { TelemetryAttributes, TelemetryProvider } from './types.js'; + +export class NoOpTelemetryProvider implements TelemetryProvider { + initialize(): void { + // No-op + } + + addAttributes(_attributes: TelemetryAttributes): void { + // No-op + } +} diff --git a/src/telemetry/tracing.ts b/src/telemetry/tracing.ts new file mode 100644 index 00000000..49b13e57 --- /dev/null +++ b/src/telemetry/tracing.ts @@ -0,0 +1,22 @@ +/** + * APM agent preload script + * + * Use with node -r flag to start APM agent before application code: + * node -r ./build/telemetry/tracing.js build/index.js + * + * Environment variables: + * - TELEMETRY_ENABLED=true - Enable telemetry + * - TELEMETRY_PROVIDER=moncloud - Use MonCloud APM + */ + +// Load .env before anything else +import dotenv from 'dotenv'; +dotenv.config(); + +import { initializeTelemetry } from './init.js'; + +try { + initializeTelemetry(); +} catch (error) { + console.warn('Failed to initialize telemetry:', error); +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 00000000..6880a572 --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,115 @@ +/** + * Telemetry types and interfaces for the MCP server + */ + +/** + * Telemetry provider interface for auto-instrumentation. + * + * Providers automatically capture HTTP requests, database calls, errors, etc. + * This interface is for initializing the provider and adding custom business context. + * + * @example OpenTelemetry implementation + * ```typescript + * export default class OpenTelemetryProvider implements TelemetryProvider { + * private trace: any; + * + * initialize(): void { + * const { NodeSDK } = require('@opentelemetry/sdk-node'); + * const sdk = new NodeSDK(); + * sdk.start(); + * this.trace = require('@opentelemetry/api').trace; + * } + * + * addAttributes(attributes: TelemetryAttributes): void { + * this.trace?.getActiveSpan()?.setAttributes(attributes); + * } + * } + * ``` + * + * @example Datadog implementation + * ```typescript + * export default class DatadogProvider implements TelemetryProvider { + * private tracer: any; + * + * initialize(): void { + * this.tracer = require('dd-trace').init(); + * } + * + * addAttributes(attributes: TelemetryAttributes): void { + * const span = this.tracer.scope().active(); + * if (span) { + * Object.entries(attributes).forEach(([k, v]) => span.setTag(k, v)); + * } + * } + * } + * ``` + */ +export interface TelemetryProvider { + /** + * Initialize the telemetry provider and start auto-instrumentation. + * + * This should start the APM agent which will automatically instrument: + * - HTTP requests and responses + * - Database queries + * - External API calls + * - Errors and exceptions + * - System metrics (CPU, memory, GC) + */ + initialize(): void; + + /** + * Add custom attributes to the current auto-generated execution context. + * These will be attached to all auto-generated spans/traces in the current context. + * + * Use this to add business-specific context that auto-instrumentation can't capture, + * such as: + * - MCP tool names + * - Tableau resource IDs (workbook, datasource, etc.) + * - User identifiers + * - Custom business dimensions + * + * @param attributes - Key-value pairs to attach to the current span + */ + addAttributes(attributes: TelemetryAttributes): void; +} + +/** + * Attributes that can be attached to telemetry data. + * Values can be strings, numbers, booleans, or undefined. + */ +export interface TelemetryAttributes { + [key: string]: string | number | boolean | undefined; +} + +/** + * Configuration for telemetry providers + */ +export interface TelemetryConfig { + /** + * Enable or disable telemetry + */ + enabled: boolean; + + /** + * Type of telemetry provider to use: + * - 'noop': No telemetry (default) + * - 'moncloud': Salesforce MonCloud (for hosted version) + * - 'custom': Load custom provider from user's filesystem + */ + provider: 'noop' | 'moncloud' | 'custom'; + + /** + * Additional configuration specific to the provider. + * + * For custom providers, must include: + * - module: Path to the provider implementation (e.g., "./my-telemetry.js") + * + * @example + * ```json + * { + * "module": "./my-otel-provider.js" + * } + * ``` + */ + providerConfig?: Record; +}