Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/chronicle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.14",
"@heroicons/react": "^2.2.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-prometheus": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@raystack/apsara": "0.55.1",
"@shikijs/rehype": "^4.0.2",
"@vitejs/plugin-react": "^6.0.1",
Expand Down
23 changes: 23 additions & 0 deletions packages/chronicle/src/server/api/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import { defineHandler } from 'nitro'
import { getExporter } from '../telemetry'

export default defineHandler(async () => {
const exporter = getExporter()
if (!exporter) {
return new Response('Telemetry not enabled', { status: 404 })
}

const metricsString = await new Promise<string>((resolve) => {
const mockRes = {
setHeader: () => mockRes,
end: (data: string) => resolve(data),
} as unknown as ServerResponse

exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
})
Comment on lines +11 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Promise may hang indefinitely if the handler fails.

The promise has no rejection path or timeout. If getMetricsRequestHandler throws an error or fails to call end(), this request will hang forever. Consider adding error handling and a timeout.

🛡️ Proposed fix with timeout and error handling
-  const metricsString = await new Promise<string>((resolve) => {
+  const metricsString = await new Promise<string>((resolve, reject) => {
+    const timeout = setTimeout(() => reject(new Error('Metrics collection timed out')), 5000)
     const mockRes = {
       setHeader: () => mockRes,
-      end: (data: string) => resolve(data),
+      end: (data: string) => {
+        clearTimeout(timeout)
+        resolve(data)
+      },
     } as unknown as ServerResponse
 
-    exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
+    try {
+      exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
+    } catch (error) {
+      clearTimeout(timeout)
+      reject(error)
+    }
-  })
+  }).catch((error) => {
+    throw new Error(`Failed to collect metrics: ${error.message}`)
+  })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const metricsString = await new Promise<string>((resolve) => {
const mockRes = {
setHeader: () => mockRes,
end: (data: string) => resolve(data),
} as unknown as ServerResponse
exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
})
const metricsString = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Metrics collection timed out')), 5000)
const mockRes = {
setHeader: () => mockRes,
end: (data: string) => {
clearTimeout(timeout)
resolve(data)
},
} as unknown as ServerResponse
try {
exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
} catch (error) {
clearTimeout(timeout)
reject(error)
}
}).catch((error) => {
throw new Error(`Failed to collect metrics: ${error.message}`)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/api/metrics.ts` around lines 11 - 18, The
Promise that produces metricsString can hang because it neither rejects on
errors nor times out; update the Promise around
exporter.getMetricsRequestHandler to accept (resolve, reject), add a timeout
timer that rejects after a short period and is cleared on resolve/reject, wrap
the call to exporter.getMetricsRequestHandler in try/catch and call reject if it
throws synchronously, and update mockRes.end to resolve only when data is
received (and to reject if data is missing or invalid); ensure the timeout is
cleared in both the resolve and reject paths so metricsString never hangs
indefinitely.


return new Response(metricsString, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
})
6 changes: 6 additions & 0 deletions packages/chronicle/src/server/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { loadApiSpecs } from '@/lib/openapi';
import { PageProvider } from '@/lib/page-context';
import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
import { App } from './App';
import { recordSSRRender } from './telemetry';

import clientAssets from './entry-client?assets=client';
import serverAssets from './entry-server?assets=ssr';
Expand Down Expand Up @@ -57,6 +58,7 @@ export default {

const assets = clientAssets.merge(serverAssets);

const renderStart = performance.now();
const stream = await renderToReadableStream(
<html lang="en">
<head>
Expand Down Expand Up @@ -91,9 +93,13 @@ export default {
</html>,
);

const renderDuration = performance.now() - renderStart;

const isApiRoute = pathname.startsWith('/apis');
const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200;

recordSSRRender(pathname, status, renderDuration);

return new Response(stream, {
status,
headers: { 'Content-Type': 'text/html;charset=utf-8' },
Expand Down
21 changes: 21 additions & 0 deletions packages/chronicle/src/server/plugins/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { definePlugin } from 'nitro'
import { loadConfig } from '@/lib/config'
import { initTelemetry, recordRequest } from '../telemetry'

export default definePlugin((nitroApp) => {
const config = loadConfig()
if (!config.telemetry?.enabled) return

initTelemetry(config)

nitroApp.hooks.hook('request', (event) => {
if (event.path === '/api/metrics') return
event.context._requestStart = performance.now()
})

nitroApp.hooks.hook('response', (res, event) => {
if (!event.context._requestStart) return
const duration = performance.now() - event.context._requestStart
recordRequest(event.method, event.path, res.status, duration)
})
})
49 changes: 49 additions & 0 deletions packages/chronicle/src/server/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Counter, Histogram } from '@opentelemetry/api'
import sdkMetrics from '@opentelemetry/sdk-metrics'
import prometheusExporter from '@opentelemetry/exporter-prometheus'
import resources from '@opentelemetry/resources'
import semconv from '@opentelemetry/semantic-conventions'
import type { ChronicleConfig } from '@/types/config'

const { MeterProvider } = sdkMetrics
const { PrometheusExporter } = prometheusExporter
const { resourceFromAttributes } = resources
const { ATTR_SERVICE_NAME } = semconv

let exporter: PrometheusExporter
let requestCounter: Counter
let requestDuration: Histogram
let ssrRenderDuration: Histogram

export function initTelemetry(config: ChronicleConfig) {
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: config.telemetry?.serviceName ?? 'chronicle',
})

exporter = new PrometheusExporter({ preventServerStart: true })
const provider = new MeterProvider({ resource, readers: [exporter] })
const meter = provider.getMeter('chronicle')

requestCounter = meter.createCounter('http_server_request_total', {
description: 'Total HTTP requests',
})
requestDuration = meter.createHistogram('http_server_request_duration_ms', {
description: 'HTTP request duration in ms',
})
ssrRenderDuration = meter.createHistogram('http_server_ssr_render_duration_ms', {
description: 'SSR render duration in ms',
})
}

export function getExporter() {
return exporter
}

export function recordRequest(method: string, route: string, status: number, durationMs: number) {
requestCounter?.add(1, { method, route, status })
requestDuration?.record(durationMs, { method, route, status })
}

export function recordSSRRender(route: string, status: number, durationMs: number) {
ssrRenderDuration?.record(durationMs, { route, status })
}
7 changes: 7 additions & 0 deletions packages/chronicle/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const analyticsSchema = z.object({
googleAnalytics: googleAnalyticsSchema.optional(),
})

const telemetrySchema = z.object({
enabled: z.boolean().optional(),
serviceName: z.string().optional(),
})

export const chronicleConfigSchema = z.object({
title: z.string(),
description: z.string().optional(),
Expand All @@ -81,6 +86,7 @@ export const chronicleConfigSchema = z.object({
api: z.array(apiSchema).optional(),
llms: llmsSchema.optional(),
analytics: analyticsSchema.optional(),
telemetry: telemetrySchema.optional(),
})

export type ChronicleConfig = z.infer<typeof chronicleConfigSchema>
Expand All @@ -97,3 +103,4 @@ export type FooterConfig = z.infer<typeof footerSchema>
export type LlmsConfig = z.infer<typeof llmsSchema>
export type AnalyticsConfig = z.infer<typeof analyticsSchema>
export type GoogleAnalyticsConfig = z.infer<typeof googleAnalyticsSchema>
export type TelemetryConfig = z.infer<typeof telemetrySchema>