mcp-server-logzio
Version:
Model Context Protocol server for Logz.io log management platform
307 lines • 10.7 kB
JavaScript
import axios from 'axios';
import { getLogger } from '../utils/logger.js';
import { ApiError, AuthenticationError, RateLimitError, isRetryableError, getRetryDelay, } from '../utils/errors.js';
/**
* Logz.io API client with retry logic and error handling
*/
export class LogzioApiClient {
axios;
config;
logger = getLogger('LogzioApiClient');
constructor(config) {
this.config = config;
this.axios = this.createAxiosInstance();
}
/**
* Create configured axios instance
*/
createAxiosInstance() {
const instance = axios.create({
baseURL: this.config.logzioUrl || 'https://api.logz.io',
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'mcp-server-logzio/0.1.0',
'X-API-TOKEN': this.config.apiKey,
},
});
// Request interceptor for logging
instance.interceptors.request.use((config) => {
this.logger.debug('Making API request', {
method: config.method,
url: config.url,
});
return config;
}, (error) => {
this.logger.error('Request interceptor error', error);
return Promise.reject(error);
});
// Response interceptor for logging and error handling
instance.interceptors.response.use((response) => {
this.logger.debug('Received API response', {
status: response.status,
url: response.config.url,
});
return response;
}, (error) => {
return this.handleResponseError(error);
});
return instance;
}
/**
* Handle response errors and convert to custom error types
*/
handleResponseError(error) {
if (axios.isAxiosError(error) && error.response) {
const { status, data } = error.response;
switch (status) {
case 401:
throw new AuthenticationError('Authentication failed. This could be due to:\n' +
'• Invalid or expired API key\n' +
'• Wrong region - we default to US region (api.logz.io)\n' +
'• If you\'re not in the US region, specify your region:\n' +
' - EU: add "region eu" to your command\n' +
' - CA: add "region ca" to your command\n' +
' - AU: add "region au" to your command\n' +
' - UK: add "region uk" to your command\n' +
' - US-West: add "region us-west" to your command\n' +
'• Check your Logz.io account URL to determine your region:\n' +
' - app.logz.io → use "region us"\n' +
' - app-eu.logz.io → use "region eu"\n' +
' - app-ca.logz.io → use "region ca"');
case 429:
const retryAfter = this.parseRetryAfter(error.response.headers);
throw new RateLimitError('Rate limit exceeded', retryAfter, { status, data });
default:
throw ApiError.fromResponse({ status, data });
}
}
throw new ApiError(error instanceof Error ? error.message : 'Unknown API error');
}
/**
* Parse retry-after header
*/
parseRetryAfter(headers) {
const retryAfter = headers['retry-after'] || headers['Retry-After'];
if (typeof retryAfter === 'string') {
const seconds = parseInt(retryAfter, 10);
return isNaN(seconds) ? undefined : seconds * 1000;
}
return undefined;
}
/**
* Make HTTP request with retry logic
*/
async makeRequest(config, attempt = 1) {
try {
const response = await this.axios(config);
return response.data;
}
catch (error) {
if (attempt <= this.config.retryAttempts && isRetryableError(error)) {
const delay = getRetryDelay(error) || this.calculateBackoffDelay(attempt);
this.logger.warn(`Request failed, retrying in ${delay}ms`, {
attempt,
maxAttempts: this.config.retryAttempts,
error: error instanceof Error ? error.message : 'Unknown error',
});
await this.sleep(delay);
return this.makeRequest(config, attempt + 1);
}
throw error;
}
}
/**
* Calculate exponential backoff delay
*/
calculateBackoffDelay(attempt) {
return Math.min(this.config.retryDelay * Math.pow(2, attempt - 1), 30000 // Max 30 seconds
);
}
/**
* Sleep for specified milliseconds
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Search logs using simple query
*/
async searchLogs(params) {
// Build Elasticsearch query body for Logz.io API
const queryBody = {
query: {
query_string: {
query: params.query
}
},
size: params.size || 50,
};
// Add time range filter if specified
if (params.from || params.to) {
queryBody.query = {
bool: {
must: [queryBody.query],
filter: {
range: {
'@timestamp': {
...(params.from && { gte: params.from }),
...(params.to && { lte: params.to })
}
}
}
}
};
}
// Add sorting
if (params.sort) {
if (params.sort.includes('desc')) {
queryBody.sort = [{ '@timestamp': { order: 'desc' } }];
}
else {
queryBody.sort = [{ '@timestamp': { order: 'asc' } }];
}
}
else {
queryBody.sort = [{ '@timestamp': { order: 'desc' } }];
}
return this.makeRequest({
method: 'POST',
url: '/v1/search',
data: queryBody,
});
}
/**
* Execute Lucene query
*/
async queryLogs(payload) {
return this.makeRequest({
method: 'POST',
url: '/v1/search',
data: payload,
});
}
/**
* Get log statistics using search aggregations
*/
async getLogStats(params) {
// Build aggregation query to get statistics
const queryBody = {
query: {
match_all: {}
},
size: 0, // We only want aggregations, not individual logs
aggs: {
time_histogram: {
date_histogram: {
field: '@timestamp',
interval: this.getTimeInterval(params.from, params.to),
order: { _key: 'desc' }
}
}
}
};
// Add time range filter if specified
if (params.from || params.to) {
queryBody.query = {
bool: {
filter: {
range: {
'@timestamp': {
...(params.from && { gte: params.from }),
...(params.to && { lte: params.to })
}
}
}
}
};
}
// Add groupBy aggregations if specified
if (params.groupBy && params.groupBy.length > 0) {
params.groupBy.forEach(field => {
queryBody.aggs[`by_${field}`] = {
terms: {
field: `${field}.keyword`,
size: 20,
order: { _count: 'desc' }
}
};
});
}
// Add common useful aggregations
queryBody.aggs.by_level = {
terms: {
field: 'level.keyword',
size: 10,
order: { _count: 'desc' }
}
};
const response = await this.makeRequest({
method: 'POST',
url: '/v1/search',
data: queryBody,
});
// Transform the response to match LogStatsResponse format
const total = typeof response.hits?.total === 'number'
? response.hits.total
: response.hits?.total?.value || 0;
return {
total,
timeRange: {
from: params.from,
to: params.to,
},
took: response.took || 0,
aggregations: response.aggregations || {},
buckets: response.aggregations?.time_histogram?.buckets?.map((bucket) => ({
key: bucket.key_as_string || bucket.key,
count: bucket.doc_count,
timestamp: bucket.key_as_string,
})) || [],
};
}
/**
* Calculate appropriate time interval for histograms
*/
getTimeInterval(from, to) {
if (!from || !to)
return '1h';
const start = new Date(from);
const end = new Date(to);
const diffHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
if (diffHours <= 6)
return '30m';
if (diffHours <= 24)
return '1h';
if (diffHours <= 72)
return '3h';
if (diffHours <= 168)
return '6h';
return '1d';
}
/**
* Test API connectivity
*/
async healthCheck() {
// Use search endpoint as health check since no dedicated health endpoint exists
try {
await this.makeRequest({
method: 'POST',
url: '/v1/search',
data: {
query: { match_all: {} },
size: 0 // Don't return any results, just test connectivity
},
});
return {
status: 'ok',
timestamp: new Date().toISOString()
};
}
catch (error) {
throw new Error(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
//# sourceMappingURL=client.js.map