jinko-mcp-dev
Version:
MCP server for Jinko Travel BFF
362 lines (361 loc) • 13 kB
JavaScript
/**
* 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();
}
}