- Update log-ingest to use internal Loki endpoint - Add standalone docker-compose for dokploy deployment - Update ROADMAP and LOGGING-PLAN with completed status - Configure proper network settings for dokploy-network
201 lines
5.5 KiB
TypeScript
201 lines
5.5 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { cors } from 'hono/cors';
|
|
import { logger } from 'hono/logger';
|
|
import { serve } from '@hono/node-server';
|
|
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from 'prom-client';
|
|
|
|
const app = new Hono();
|
|
const register = new Registry();
|
|
|
|
collectDefaultMetrics({ register });
|
|
|
|
const metrics = {
|
|
eventsReceived: new Counter({
|
|
name: 'log_ingest_events_total',
|
|
help: 'Total events received',
|
|
labelNames: ['stack_name', 'event_type'],
|
|
registers: [register]
|
|
}),
|
|
|
|
eventProcessingDuration: new Histogram({
|
|
name: 'log_ingest_processing_duration_seconds',
|
|
help: 'Event processing duration',
|
|
labelNames: ['stack_name'],
|
|
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
|
|
registers: [register]
|
|
}),
|
|
|
|
lokiPushErrors: new Counter({
|
|
name: 'log_ingest_loki_errors_total',
|
|
help: 'Loki push errors',
|
|
registers: [register]
|
|
}),
|
|
|
|
activeStacks: new Gauge({
|
|
name: 'log_ingest_active_stacks',
|
|
help: 'Number of active stacks sending events',
|
|
registers: [register]
|
|
})
|
|
};
|
|
|
|
const LOKI_URL = process.env.LOKI_URL || 'http://loki:3100';
|
|
|
|
interface LogEvent {
|
|
timestamp?: string;
|
|
stack_name: string;
|
|
session_id?: string;
|
|
event_type: 'session_start' | 'session_end' | 'message' | 'tool_use' | 'error' | 'mcp_connect' | 'mcp_disconnect';
|
|
data?: {
|
|
role?: 'user' | 'assistant' | 'system';
|
|
model?: string;
|
|
agent?: string;
|
|
tool?: string;
|
|
tokens_in?: number;
|
|
tokens_out?: number;
|
|
duration_ms?: number;
|
|
success?: boolean;
|
|
error_code?: string;
|
|
error_message?: string;
|
|
content_length?: number;
|
|
content_hash?: string;
|
|
mcp_server?: string;
|
|
};
|
|
}
|
|
|
|
const activeStacksSet = new Set<string>();
|
|
|
|
async function pushToLoki(events: LogEvent[]): Promise<void> {
|
|
const streams: Record<string, { stream: Record<string, string>; values: [string, string][] }> = {};
|
|
|
|
for (const event of events) {
|
|
const labels = {
|
|
job: 'ai-stack-events',
|
|
stack_name: event.stack_name,
|
|
event_type: event.event_type,
|
|
...(event.session_id && { session_id: event.session_id }),
|
|
...(event.data?.model && { model: event.data.model }),
|
|
...(event.data?.agent && { agent: event.data.agent }),
|
|
...(event.data?.tool && { tool: event.data.tool })
|
|
};
|
|
|
|
const labelKey = JSON.stringify(labels);
|
|
|
|
if (!streams[labelKey]) {
|
|
streams[labelKey] = {
|
|
stream: labels,
|
|
values: []
|
|
};
|
|
}
|
|
|
|
const timestamp = event.timestamp || new Date().toISOString();
|
|
const nanoseconds = BigInt(new Date(timestamp).getTime()) * BigInt(1_000_000);
|
|
|
|
streams[labelKey].values.push([
|
|
nanoseconds.toString(),
|
|
JSON.stringify(event)
|
|
]);
|
|
}
|
|
|
|
const payload = {
|
|
streams: Object.values(streams)
|
|
};
|
|
|
|
const response = await fetch(`${LOKI_URL}/loki/api/v1/push`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Loki push failed: ${response.status} ${text}`);
|
|
}
|
|
}
|
|
|
|
app.use('*', cors());
|
|
app.use('*', logger());
|
|
|
|
app.get('/health', (c) => {
|
|
return c.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
app.get('/metrics', async (c) => {
|
|
metrics.activeStacks.set(activeStacksSet.size);
|
|
c.header('Content-Type', register.contentType);
|
|
return c.text(await register.metrics());
|
|
});
|
|
|
|
app.post('/ingest', async (c) => {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const body = await c.req.json();
|
|
const events: LogEvent[] = Array.isArray(body) ? body : [body];
|
|
|
|
for (const event of events) {
|
|
if (!event.stack_name || !event.event_type) {
|
|
return c.json({ error: 'Missing required fields: stack_name, event_type' }, 400);
|
|
}
|
|
|
|
activeStacksSet.add(event.stack_name);
|
|
metrics.eventsReceived.inc({ stack_name: event.stack_name, event_type: event.event_type });
|
|
}
|
|
|
|
await pushToLoki(events);
|
|
|
|
const duration = (Date.now() - startTime) / 1000;
|
|
for (const event of events) {
|
|
metrics.eventProcessingDuration.observe({ stack_name: event.stack_name }, duration);
|
|
}
|
|
|
|
return c.json({ success: true, count: events.length });
|
|
} catch (error) {
|
|
metrics.lokiPushErrors.inc();
|
|
console.error('Ingest error:', error);
|
|
return c.json({ error: 'Failed to process events', details: String(error) }, 500);
|
|
}
|
|
});
|
|
|
|
app.post('/ingest/batch', async (c) => {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const body = await c.req.json();
|
|
|
|
if (!Array.isArray(body)) {
|
|
return c.json({ error: 'Expected array of events' }, 400);
|
|
}
|
|
|
|
const events: LogEvent[] = body;
|
|
|
|
for (const event of events) {
|
|
if (!event.stack_name || !event.event_type) {
|
|
continue;
|
|
}
|
|
activeStacksSet.add(event.stack_name);
|
|
metrics.eventsReceived.inc({ stack_name: event.stack_name, event_type: event.event_type });
|
|
}
|
|
|
|
await pushToLoki(events);
|
|
|
|
const duration = (Date.now() - startTime) / 1000;
|
|
metrics.eventProcessingDuration.observe({ stack_name: 'batch' }, duration);
|
|
|
|
return c.json({ success: true, count: events.length });
|
|
} catch (error) {
|
|
metrics.lokiPushErrors.inc();
|
|
console.error('Batch ingest error:', error);
|
|
return c.json({ error: 'Failed to process batch', details: String(error) }, 500);
|
|
}
|
|
});
|
|
|
|
const port = parseInt(process.env.PORT || '3000');
|
|
console.log(`Log ingest service starting on port ${port}`);
|
|
|
|
serve({
|
|
fetch: app.fetch,
|
|
port
|
|
});
|