Skip to main content
Glama
index.ts13.2 kB
/** * @prodisco/loki-client * * A TypeScript client for Grafana Loki REST API. * Provides a clean, typed interface for querying logs from Loki. */ // ============================================================================ // Types // ============================================================================ export interface LokiClientOptions { /** Loki server URL (e.g., "http://localhost:3100") */ baseUrl: string; /** Optional tenant ID for multi-tenant Loki */ tenantId?: string; /** Request timeout in milliseconds (default: 30000) */ timeout?: number; } export interface QueryRangeOptions { /** Start time (ISO string, Unix timestamp in seconds, or relative like "1h") */ start?: string | number; /** End time (ISO string, Unix timestamp in seconds) */ end?: string | number; /** Relative time range (e.g., "1h", "30m", "24h") - alternative to start/end */ since?: string; /** Maximum number of log entries to return */ limit?: number; /** Query direction: "forward" or "backward" (default: "backward") */ direction?: 'forward' | 'backward'; } export interface LabelValuesOptions { /** Start time for label values query */ start?: string | number; /** End time for label values query */ end?: string | number; /** Relative time range */ since?: string; /** Optional LogQL query to filter label values */ query?: string; } export interface LogEntry { /** Timestamp as Date object */ timestamp: Date; /** Timestamp in nanoseconds (string) */ timestampNanos: string; /** Log line content */ line: string; /** Stream labels */ labels: Record<string, string>; } export interface LogStream { /** Stream labels */ labels: Record<string, string>; /** Log entries in this stream */ entries: Array<{ timestamp: Date; timestampNanos: string; line: string; }>; } export interface MetricSample { /** Timestamp as Date object */ timestamp: Date; /** Metric value */ value: number; } export interface MetricSeries { /** Metric labels */ labels: Record<string, string>; /** Sample values over time */ values: MetricSample[]; } export interface QueryRangeLogsResult { /** Parsed log entries (flattened from all streams) */ logs: LogEntry[]; /** Raw streams with their labels */ streams: LogStream[]; /** Statistics from Loki */ stats?: Record<string, unknown>; } export interface QueryRangeMatrixResult { /** Metric series */ metrics: MetricSeries[]; /** Statistics from Loki */ stats?: Record<string, unknown>; } // ============================================================================ // LokiClient Implementation // ============================================================================ export class LokiClient { private baseUrl: string; private tenantId?: string; private timeout: number; constructor(options: LokiClientOptions) { // Remove trailing slash from baseUrl this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.tenantId = options.tenantId; this.timeout = options.timeout ?? 30000; } /** * Build request headers */ private getHeaders(): Record<string, string> { const headers: Record<string, string> = { 'Content-Type': 'application/json', }; if (this.tenantId) { headers['X-Scope-OrgID'] = this.tenantId; } return headers; } /** * Make HTTP request to Loki */ private async request<T>(path: string, params?: Record<string, string>): Promise<T> { const url = new URL(path, this.baseUrl); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== '') { url.searchParams.set(key, value); } }); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(url.toString(), { method: 'GET', headers: this.getHeaders(), signal: controller.signal, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Loki request failed: ${response.status} ${response.statusText} - ${errorText}`); } return await response.json() as T; } finally { clearTimeout(timeoutId); } } /** * Parse relative time string (e.g., "1h", "30m", "24h") to nanoseconds ago */ private parseRelativeTime(since: string): string { const match = since.match(/^(\d+)(s|m|h|d|w)$/); if (!match) { throw new Error(`Invalid relative time format: ${since}. Use format like "1h", "30m", "24h"`); } const value = parseInt(match[1], 10); const unit = match[2]; const multipliers: Record<string, number> = { 's': 1, 'm': 60, 'h': 3600, 'd': 86400, 'w': 604800, }; const seconds = value * multipliers[unit]; const nowNanos = BigInt(Date.now()) * BigInt(1000000); const startNanos = nowNanos - BigInt(seconds) * BigInt(1000000000); return startNanos.toString(); } /** * Convert time parameter to nanoseconds string */ private timeToNanos(time: string | number | undefined, defaultValue?: string): string | undefined { if (time === undefined) return defaultValue; if (typeof time === 'number') { // Assume seconds, convert to nanoseconds return (BigInt(time) * BigInt(1000000000)).toString(); } // Check if it's a relative time if (/^\d+(s|m|h|d|w)$/.test(time)) { return this.parseRelativeTime(time); } // Try parsing as ISO date const date = new Date(time); if (!isNaN(date.getTime())) { return (BigInt(date.getTime()) * BigInt(1000000)).toString(); } // Return as-is (might be nanoseconds already) return time; } /** * Get current time in nanoseconds */ private nowNanos(): string { return (BigInt(Date.now()) * BigInt(1000000)).toString(); } // ========================================================================== // Public API Methods // ========================================================================== /** * Get all label names. * * @example * const labels = await client.labels(); * console.log(labels); // ["app", "namespace", "pod", ...] */ async labels(options?: LabelValuesOptions): Promise<string[]> { let start: string | undefined; let end: string | undefined; if (options?.since) { start = this.parseRelativeTime(options.since); end = this.nowNanos(); } else { start = this.timeToNanos(options?.start); end = this.timeToNanos(options?.end); } const params: Record<string, string> = {}; if (start) params.start = start; if (end) params.end = end; const response = await this.request<{ status: string; data: string[] }>( '/loki/api/v1/labels', params ); return response.data; } /** * Get all values for a specific label. * * @example * const namespaces = await client.labelValues('namespace'); * console.log(namespaces); // ["default", "kube-system", ...] */ async labelValues(label: string, options?: LabelValuesOptions): Promise<string[]> { let start: string | undefined; let end: string | undefined; if (options?.since) { start = this.parseRelativeTime(options.since); end = this.nowNanos(); } else { start = this.timeToNanos(options?.start); end = this.timeToNanos(options?.end); } const params: Record<string, string> = {}; if (start) params.start = start; if (end) params.end = end; if (options?.query) params.query = options.query; const response = await this.request<{ status: string; data: string[] }>( `/loki/api/v1/label/${encodeURIComponent(label)}/values`, params ); return response.data; } /** * Get log stream series matching selectors. * * @example * const series = await client.series(['{namespace="default"}']); * console.log(series); // [{ app: "nginx", namespace: "default", ... }, ...] */ async series( selectors: string[], options?: { start?: string | number; end?: string | number; since?: string } ): Promise<Record<string, string>[]> { let start: string | undefined; let end: string | undefined; if (options?.since) { start = this.parseRelativeTime(options.since); end = this.nowNanos(); } else { start = this.timeToNanos(options?.start); end = this.timeToNanos(options?.end); } const params: Record<string, string> = {}; if (start) params.start = start; if (end) params.end = end; // Loki expects multiple match[] parameters selectors.forEach((s, i) => { params[`match[${i}]`] = s; }); const response = await this.request<{ status: string; data: Record<string, string>[] }>( '/loki/api/v1/series', params ); return response.data; } /** * Query logs using LogQL. Returns parsed log entries. * * @example * const result = await client.queryRange('{namespace="kube-system"}', { since: '1h', limit: 100 }); * result.logs.forEach(log => { * console.log(`[${log.timestamp.toISOString()}] ${log.line}`); * }); */ async queryRange(logQL: string, options?: QueryRangeOptions): Promise<QueryRangeLogsResult> { let start: string | undefined; let end: string | undefined; if (options?.since) { start = this.parseRelativeTime(options.since); end = this.nowNanos(); } else { start = this.timeToNanos(options?.start); end = this.timeToNanos(options?.end); } // Default to last hour if no time specified if (!start) { start = this.parseRelativeTime('1h'); } if (!end) { end = this.nowNanos(); } const params: Record<string, string> = { query: logQL, start, end, }; if (options?.limit) params.limit = options.limit.toString(); if (options?.direction) params.direction = options.direction; const response = await this.request<{ status: string; data: { resultType: string; result: Array<{ stream: Record<string, string>; values: Array<[string, string]>; }>; stats?: Record<string, unknown>; }; }>('/loki/api/v1/query_range', params); // Parse streams result const streams: LogStream[] = response.data.result.map(stream => ({ labels: stream.stream, entries: stream.values.map(([ts, line]) => ({ timestamp: new Date(parseInt(ts.slice(0, -6), 10)), timestampNanos: ts, line, })), })); // Flatten logs for convenience const logs: LogEntry[] = streams.flatMap(stream => stream.entries.map(entry => ({ ...entry, labels: stream.labels, })) ); // Sort by timestamp descending (most recent first) logs.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); return { logs, streams, stats: response.data.stats, }; } /** * Query for matrix/metric results. Use for LogQL metric queries like rate() or count_over_time(). * * @example * const result = await client.queryRangeMatrix('rate({app="nginx"}[5m])', { since: '1h' }); * result.metrics.forEach(m => { * console.log(m.labels, m.values); * }); */ async queryRangeMatrix(logQL: string, options?: QueryRangeOptions): Promise<QueryRangeMatrixResult> { let start: string | undefined; let end: string | undefined; if (options?.since) { start = this.parseRelativeTime(options.since); end = this.nowNanos(); } else { start = this.timeToNanos(options?.start); end = this.timeToNanos(options?.end); } // Default to last hour if no time specified if (!start) { start = this.parseRelativeTime('1h'); } if (!end) { end = this.nowNanos(); } const params: Record<string, string> = { query: logQL, start, end, }; if (options?.limit) params.limit = options.limit.toString(); const response = await this.request<{ status: string; data: { resultType: string; result: Array<{ metric: Record<string, string>; values: Array<[number, string]>; }>; stats?: Record<string, unknown>; }; }>('/loki/api/v1/query_range', params); // Parse matrix result const metrics: MetricSeries[] = response.data.result.map(series => ({ labels: series.metric, values: series.values.map(([ts, val]) => ({ timestamp: new Date(ts * 1000), value: parseFloat(val), })), })); return { metrics, stats: response.data.stats, }; } /** * Check if Loki is ready. * * @example * const ready = await client.ready(); * console.log(ready); // true */ async ready(): Promise<boolean> { try { const response = await fetch(`${this.baseUrl}/ready`, { method: 'GET', headers: this.getHeaders(), }); return response.ok; } catch { return false; } } } // Default export for convenience export default LokiClient;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/harche/ProDisco'

If you have feedback or need assistance with the MCP directory API, please join our Discord server