diff --git a/docs/chronicle.yaml b/docs/chronicle.yaml index 045b114..5a3de82 100644 --- a/docs/chronicle.yaml +++ b/docs/chronicle.yaml @@ -17,6 +17,9 @@ search: llms: enabled: true +telemetry: + enabled: true + footer: copyright: "© 2026 Raystack. All rights reserved." links: diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 33432af..1c8ccb7 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -62,6 +62,11 @@ analytics: enabled: true googleAnalytics: measurementId: G-XXXXXXXXXX + +telemetry: + enabled: true + serviceName: my-docs + port: 9090 ``` ## Reference @@ -267,6 +272,25 @@ analytics: | `enabled` | `boolean` | Enable/disable analytics | `false` | | `googleAnalytics.measurementId` | `string` | Google Analytics measurement ID | — | +### telemetry + +Prometheus metrics export via OpenTelemetry. When enabled, metrics are served on a separate port. + +```yaml +telemetry: + enabled: true + serviceName: my-docs + port: 9090 +``` + +| Field | Type | Description | Default | +|-------|------|-------------|---------| +| `enabled` | `boolean` | Enable/disable telemetry | `false` | +| `serviceName` | `string` | OpenTelemetry service name | `chronicle` | +| `port` | `number` | Port for Prometheus metrics endpoint | `9090` | + +Metrics are available at `http://localhost:/metrics` in Prometheus exposition format. + ## Defaults If `chronicle.yaml` is missing or fields are omitted, these defaults apply: diff --git a/package.json b/package.json index c251626..6421a8f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build:cli": "bun run --filter @raystack/chronicle build:cli", "dev:docs": "./packages/chronicle/bin/chronicle.js dev --config docs/chronicle.yaml", + "start:docs": "./packages/chronicle/bin/chronicle.js start --config docs/chronicle.yaml", "build:docs": "./packages/chronicle/bin/chronicle.js build --config docs/chronicle.yaml" } } diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 2bb4232..0252688 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -22,6 +22,7 @@ interface PageContextValue { config: ChronicleConfig; tree: Root; page: PageData | null; + errorStatus: number | null; apiSpecs: ApiSpec[]; } @@ -35,6 +36,7 @@ export function usePageContext(): PageContextValue { config: { title: 'Documentation' }, tree: { name: 'root', children: [] } as Root, page: null, + errorStatus: null, apiSpecs: [] }; } @@ -50,6 +52,16 @@ interface PageProviderProps { children: ReactNode; } +function isApisRoute(pathname: string): boolean { + return pathname === '/apis' || pathname.startsWith('/apis/'); +} + +function getInitialErrorStatus(page: PageData | null, pathname: string): number | null { + if (page) return null; + if (pathname === '/' || isApisRoute(pathname)) return null; + return 404; +} + export function PageProvider({ initialConfig, initialTree, @@ -61,6 +73,7 @@ export function PageProvider({ const { pathname } = useLocation(); const [tree] = useState(initialTree); const [page, setPage] = useState(initialPage); + const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, pathname)); const [apiSpecs, setApiSpecs] = useState(initialApiSpecs); const [currentPath, setCurrentPath] = useState(pathname); @@ -70,7 +83,7 @@ export function PageProvider({ const cancelled = { current: false }; - if (pathname.startsWith('/apis')) { + if (isApisRoute(pathname)) { if (apiSpecs.length === 0) { fetch('/api/specs') .then(res => res.json()) @@ -89,21 +102,36 @@ export function PageProvider({ const apiPath = slug.length === 0 ? '/api/page/' : `/api/page/${slug.join('/')}`; fetch(apiPath) - .then(res => res.json()) - .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string }) => { - if (cancelled.current) return; + .then(res => { + if (!res.ok) { + if (!cancelled.current) { + setPage(null); + setErrorStatus(res.status); + } + return; + } + return res.json(); + }) + .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string } | undefined) => { + if (cancelled.current || !data) return; const { content, toc } = await loadMdx(data.originalPath || data.relativePath); if (cancelled.current) return; + setErrorStatus(null); setPage({ slug, frontmatter: data.frontmatter, content, toc }); }) - .catch(() => {}); + .catch(() => { + if (!cancelled.current) { + setPage(null); + setErrorStatus(500); + } + }); return () => { cancelled.current = true; }; }, [pathname]); return ( {children} diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 3ebb792..e1710b4 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -1,5 +1,6 @@ import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; +import { NotFound } from '@/pages/NotFound'; import { getTheme } from '@/themes/registry'; interface DocsPageProps { @@ -7,8 +8,10 @@ interface DocsPageProps { } export function DocsPage({ slug }: DocsPageProps) { - const { config, tree, page } = usePageContext(); + const { config, tree, page, errorStatus } = usePageContext(); + if (errorStatus === 404) return ; + if (errorStatus) return ; if (!page) return null; const { Page } = getTheme(config.theme?.name); diff --git a/packages/chronicle/src/pages/NotFound.module.css b/packages/chronicle/src/pages/NotFound.module.css new file mode 100644 index 0000000..5228309 --- /dev/null +++ b/packages/chronicle/src/pages/NotFound.module.css @@ -0,0 +1,3 @@ +.emptyState { + justify-content: center; +} diff --git a/packages/chronicle/src/pages/NotFound.tsx b/packages/chronicle/src/pages/NotFound.tsx index ab92aab..8cea1ad 100644 --- a/packages/chronicle/src/pages/NotFound.tsx +++ b/packages/chronicle/src/pages/NotFound.tsx @@ -1,17 +1,12 @@ -import { Flex, Headline, Text } from '@raystack/apsara'; +import { EmptyState } from '@raystack/apsara'; +import styles from './NotFound.module.css'; export function NotFound() { return ( - - - 404 - - Page not found - + ); } diff --git a/packages/chronicle/src/server/api/metrics.ts b/packages/chronicle/src/server/api/metrics.ts deleted file mode 100644 index 57b1d78..0000000 --- a/packages/chronicle/src/server/api/metrics.ts +++ /dev/null @@ -1,23 +0,0 @@ -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((resolve) => { - const mockRes = { - setHeader: () => mockRes, - end: (data: string) => resolve(data), - } as unknown as ServerResponse - - exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes) - }) - - return new Response(metricsString, { - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, - }) -}) diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index 0ccd49b..fe23e1a 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -9,8 +9,8 @@ import { loadConfig } from '@/lib/config'; import { loadApiSpecs } from '@/lib/openapi'; import { PageProvider } from '@/lib/page-context'; import { getPageTree, getPage, loadPageModule, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source'; +import { useNitroApp } from 'nitro/app'; import { App } from './App'; -import { recordSSRRender } from './telemetry'; import clientAssets from './entry-client?assets=client'; import serverAssets from './entry-server?assets=ssr'; @@ -98,7 +98,7 @@ export default { const isApiRoute = pathname.startsWith('/apis'); const status = !page && !isApiRoute && slug.length > 0 ? 404 : 200; - recordSSRRender(pathname, status, renderDuration); + useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration); return new Response(stream, { status, diff --git a/packages/chronicle/src/server/plugins/telemetry.ts b/packages/chronicle/src/server/plugins/telemetry.ts index e5189f2..7f31d5f 100644 --- a/packages/chronicle/src/server/plugins/telemetry.ts +++ b/packages/chronicle/src/server/plugins/telemetry.ts @@ -1,21 +1,61 @@ +import type { Counter, Histogram } from '@opentelemetry/api' +import { MeterProvider } from '@opentelemetry/sdk-metrics' +import { PrometheusExporter } from '@opentelemetry/exporter-prometheus' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions' +import type { H3Event } from 'h3' import { definePlugin } from 'nitro' import { loadConfig } from '@/lib/config' -import { initTelemetry, recordRequest } from '../telemetry' + +declare module 'nitro/types' { + interface NitroRuntimeHooks { + 'chronicle:ssr-rendered': (route: string, status: number, durationMs: number) => void + } +} export default definePlugin((nitroApp) => { const config = loadConfig() if (!config.telemetry?.enabled) return - initTelemetry(config) + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: config.telemetry?.serviceName ?? 'chronicle', + }) + + const port = config.telemetry?.port ?? 9090 + const exporter = new PrometheusExporter({ port }) + const provider = new MeterProvider({ resource, readers: [exporter] }) + const meter = provider.getMeter('chronicle') + + const requestCounter: Counter = meter.createCounter('http_server_request_total', { + description: 'Total HTTP requests', + }) + const requestDuration: Histogram = meter.createHistogram('http_server_request_duration_ms', { + description: 'HTTP request duration in ms', + }) + const ssrRenderDuration: Histogram = meter.createHistogram('http_server_ssr_render_duration_ms', { + description: 'SSR render duration in ms', + }) + + nitroApp.hooks.hook('close', async () => { + await provider.shutdown() + await exporter.shutdown() + }) + + nitroApp.hooks.hook('chronicle:ssr-rendered', (route, status, durationMs) => { + ssrRenderDuration.record(durationMs, { route, status }) + }) nitroApp.hooks.hook('request', (event) => { - if (event.path === '/api/metrics') return - event.context._requestStart = performance.now() + (event as H3Event).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) + const start = (event as H3Event).context._requestStart as number | undefined + if (start === undefined) return + const duration = performance.now() - start + const method = event.req.method + const route = new URL(event.req.url).pathname + requestCounter.add(1, { method, route, status: res.status }) + requestDuration.record(duration, { method, route, status: res.status }) }) }) diff --git a/packages/chronicle/src/server/telemetry.ts b/packages/chronicle/src/server/telemetry.ts deleted file mode 100644 index 77c4563..0000000 --- a/packages/chronicle/src/server/telemetry.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 }) -} diff --git a/packages/chronicle/src/types/config.ts b/packages/chronicle/src/types/config.ts index 5959722..f28a322 100644 --- a/packages/chronicle/src/types/config.ts +++ b/packages/chronicle/src/types/config.ts @@ -70,6 +70,7 @@ const analyticsSchema = z.object({ const telemetrySchema = z.object({ enabled: z.boolean().optional(), serviceName: z.string().optional(), + port: z.number().int().min(1).max(65535).default(9090), }) export const chronicleConfigSchema = z.object({