UNPKG

jinko-mcp-dev

Version:

MCP server for Jinko Travel BFF

362 lines (361 loc) 13 kB
/** * Unified telemetry implementation using HTTP OTLP exports * Works in both Node.js and Cloudflare Workers environments */ import { VERSION_INFO } from '../version.js'; const defaultConfig = { endpoint: 'https://log.api.jinko.so', serviceName: VERSION_INFO.name, serviceVersion: VERSION_INFO.version, headers: {}, timeout: 10000, }; // Log levels export var LogLevel; (function (LogLevel) { LogLevel["DEBUG"] = "debug"; LogLevel["INFO"] = "info"; LogLevel["WARN"] = "warn"; LogLevel["ERROR"] = "error"; })(LogLevel || (LogLevel = {})); // Unified Logger export class UnifiedLogger { config; resource; constructor(config = {}) { this.config = { ...defaultConfig, ...config }; this.resource = { attributes: [ { key: 'service.name', value: { stringValue: this.config.serviceName } }, { key: 'service.version', value: { stringValue: this.config.serviceVersion } }, { key: 'telemetry.sdk.name', value: { stringValue: 'jinko-mcp-unified' } }, { key: 'telemetry.sdk.version', value: { stringValue: this.config.serviceVersion } }, ], }; } getSeverityNumber(level) { switch (level) { case LogLevel.DEBUG: return 5; case LogLevel.INFO: return 9; case LogLevel.WARN: return 13; case LogLevel.ERROR: return 17; default: return 9; } } createLogRecord(level, message, attributes = {}) { const now = Date.now() * 1000000; // Convert to nanoseconds const otlpAttributes = Object.entries(attributes).map(([key, value]) => ({ key, value: typeof value === 'string' ? { stringValue: value } : { stringValue: String(value) } })); return { timeUnixNano: now.toString(), severityNumber: this.getSeverityNumber(level), severityText: level.toUpperCase(), body: { stringValue: message }, attributes: otlpAttributes, }; } async sendLogs(logRecords) { const payload = { resourceLogs: [{ resource: this.resource, scopeLogs: [{ scope: { name: 'jinko-mcp-logger', version: this.config.serviceVersion, }, logRecords, }], }], }; try { const response = await fetch(`${this.config.endpoint}/v1/logs`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers, }, body: JSON.stringify(payload), }); if (!response.ok) { console.error(`Failed to send logs: ${response.status} ${response.statusText}`); } else { console.error(`Logs sent successfully: ${response.status}`); } } catch (error) { console.error('Error sending logs to OTLP collector:', error); } } async log(level, message, attributes = {}) { // Always log to console for immediate feedback const logEntry = { timestamp: new Date().toISOString(), level: level.toUpperCase(), service: this.config.serviceName, version: this.config.serviceVersion, message, ...attributes, }; // Send to OTLP collector const logRecord = this.createLogRecord(level, message, attributes); await this.sendLogs([logRecord]); } async debug(message, attributes = {}) { await this.log(LogLevel.DEBUG, message, attributes); } async info(message, attributes = {}) { await this.log(LogLevel.INFO, message, attributes); } async warn(message, attributes = {}) { await this.log(LogLevel.WARN, message, attributes); } async error(message, attributes = {}) { await this.log(LogLevel.ERROR, message, attributes); } // Convenience methods for MCP operations async logToolCall(toolName, params, message = 'Tool called') { await this.info(message, { operation: 'tool_call', toolName, params: this.sanitizeParams(params), }); } async logToolResult(toolName, duration, status, message = 'Tool completed') { await this.info(message, { operation: 'tool_result', toolName, duration, status, }); } async logApiCall(endpoint, method, message = 'API call started') { await this.info(message, { operation: 'api_call_start', endpoint: this.sanitizeEndpoint(endpoint), method, }); } async logApiResult(endpoint, method, duration, status, message) { const logMessage = message || `API call completed: ${status}`; const level = status >= 400 ? LogLevel.ERROR : LogLevel.INFO; await this.log(level, logMessage, { operation: 'api_call_result', endpoint: this.sanitizeEndpoint(endpoint), method, duration, status, }); } async logError(error, context = {}) { await this.error(error.message, { error_name: error.name, error_stack: error.stack, ...context, }); } sanitizeParams(params) { if (!params) return {}; const sanitized = { ...params }; const sensitiveKeys = ['password', 'token', 'key', 'secret', 'auth']; for (const key of Object.keys(sanitized)) { if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive))) { sanitized[key] = '[REDACTED]'; } } return sanitized; } sanitizeEndpoint(endpoint) { try { const url = new URL(endpoint); return `${url.protocol}//${url.host}${url.pathname}`; } catch { return endpoint.split('?')[0]; } } } // Unified Metrics export class UnifiedMetrics { config; resource; constructor(config = {}) { this.config = { ...defaultConfig, ...config }; this.resource = { attributes: [ { key: 'service.name', value: { stringValue: this.config.serviceName } }, { key: 'service.version', value: { stringValue: this.config.serviceVersion } }, { key: 'telemetry.sdk.name', value: { stringValue: 'jinko-mcp-unified' } }, { key: 'telemetry.sdk.version', value: { stringValue: this.config.serviceVersion } }, ], }; // Immediate export - no batching or timers to avoid Cloudflare Workers global scope issues } incrementCounter(name, value = 1, attributes = {}) { this.exportCounter(name, value, attributes); } recordHistogramValue(name, value, attributes = {}) { this.exportHistogram(name, value, attributes); } async exportCounter(name, value, attributes) { const now = Date.now() * 1000000; // Convert to nanoseconds // Add version information to attributes const enrichedAttributes = { ...attributes, version: this.config.serviceVersion, service: this.config.serviceName, }; const metric = { name, description: `Counter metric: ${name}`, sum: { dataPoints: [{ timeUnixNano: now.toString(), value, attributes: Object.entries(enrichedAttributes).map(([k, v]) => ({ key: k, value: { stringValue: v } })), }], aggregationTemporality: 1, // Cumulative isMonotonic: true, }, }; await this.sendMetrics([metric]); } async exportHistogram(name, value, attributes) { const now = Date.now() * 1000000; // Convert to nanoseconds // Add version information to attributes const enrichedAttributes = { ...attributes, version: this.config.serviceVersion, service: this.config.serviceName, }; const metric = { name, description: `Histogram metric: ${name}`, unit: name.includes('duration') ? 's' : undefined, histogram: { dataPoints: [{ timeUnixNano: now.toString(), count: '1', sum: value, bucketCounts: ['0', '1'], // Simple bucket: one value explicitBounds: [value], attributes: Object.entries(enrichedAttributes).map(([k, v]) => ({ key: k, value: { stringValue: v } })), }], aggregationTemporality: 1, // Cumulative }, }; await this.sendMetrics([metric]); } async sendMetrics(metrics) { const payload = { resourceMetrics: [{ resource: this.resource, scopeMetrics: [{ scope: { name: 'jinko-mcp-metrics', version: this.config.serviceVersion, }, metrics, }], }], }; try { const response = await fetch(`${this.config.endpoint}/v1/metrics`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers, }, body: JSON.stringify(payload), }); if (!response.ok) { console.error(`Failed to send metrics: ${response.status} ${response.statusText}`); } } catch (error) { console.error('Error sending metrics to OTLP collector:', error); } } // Convenience methods for common metrics recordToolCall(toolName, duration, status) { this.incrementCounter('mcp_tool_calls_total', 1, { tool_name: toolName, status }); this.recordHistogramValue('mcp_tool_duration_seconds', duration, { tool_name: toolName, status }); } // Async helper methods for OTLP export async recordCounter(name, value, attributes = {}) { await this.exportCounter(name, value, attributes); } async recordHistogram(name, value, attributes = {}) { await this.exportHistogram(name, value, attributes); } sanitizeEndpoint(endpoint) { try { const url = new URL(endpoint); return `${url.protocol}//${url.host}${url.pathname}`; } catch { return endpoint.split('?')[0]; } } // Hotel-specific metrics methods async recordHotelSearchCall(params) { await this.recordCounter('hotel_search_calls_total', 1, { location: params.location_name || 'unknown', status: params.status }); await this.recordHistogram('hotel_search_nights', params.nights, { location: params.location_name || 'unknown' }); await this.recordHistogram('hotel_search_travelers', params.total_travelers, { location: params.location_name || 'unknown' }); } async recordHotelSearchResults(count) { await this.recordHistogram('hotel_search_results_count', count); await this.recordCounter('hotel_search_results_total', count); } async recordApiCall(endpoint, method, duration, status) { await this.recordCounter('api_calls_total', 1, { endpoint: this.sanitizeEndpoint(endpoint), method, status }); await this.recordHistogram('api_call_duration_ms', duration, { endpoint: this.sanitizeEndpoint(endpoint), method, status }); } async shutdown() { // No batching to flush - immediate export means nothing to do } } // Unified Instrumentation export class UnifiedInstrumentation { logger; metrics; constructor(config = {}) { this.logger = new UnifiedLogger(config); this.metrics = new UnifiedMetrics(config); } getLogger() { return this.logger; } getMetrics() { return this.metrics; } async shutdown() { await this.metrics.shutdown(); } }