diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62df9dee..2ef821ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,8 @@ jobs: - name: Smoke test dev server shell: bash + env: + NITRO_DEV_RUNNER: ${{ matrix.os == 'windows-latest' && 'self' || '' }} run: | ./packages/chronicle/bin/chronicle.js dev --config examples/basic/chronicle.yaml --port 3001 & DEV_PID=$! diff --git a/packages/chronicle/src/server/plugins/telemetry.test.ts b/packages/chronicle/src/server/plugins/telemetry.test.ts new file mode 100644 index 00000000..003634d5 --- /dev/null +++ b/packages/chronicle/src/server/plugins/telemetry.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { toEndpoint } from './telemetry' + +describe('toEndpoint', () => { + test('root path', () => { + expect(toEndpoint('/')).toBe('/') + }) + + test('docs pages map to /docs/:slug', () => { + expect(toEndpoint('/docs/intro')).toBe('/docs/:slug') + expect(toEndpoint('/docs/guides/installation')).toBe('/docs/:slug') + expect(toEndpoint('/developer/gettingstarted/auth')).toBe('/docs/:slug') + }) + + test('api internal routes keep exact path', () => { + expect(toEndpoint('/api/page')).toBe('/api/page') + expect(toEndpoint('/api/search')).toBe('/api/search') + expect(toEndpoint('/api/specs')).toBe('/api/specs') + expect(toEndpoint('/api/health')).toBe('/api/health') + }) + + test('api reference pages map to /apis/:slug', () => { + expect(toEndpoint('/apis/petstore/listPets')).toBe('/apis/:slug') + expect(toEndpoint('/apis/frontier/getUser')).toBe('/apis/:slug') + }) + + test('assets map to /assets/:file', () => { + expect(toEndpoint('/assets/chunk-abc123.js')).toBe('/assets/:file') + expect(toEndpoint('/assets/style-xyz.css')).toBe('/assets/:file') + }) + + test('content paths map to /_content/:path', () => { + expect(toEndpoint('/_content/docs/intro.mdx')).toBe('/_content/:path') + }) + + test('static routes keep exact path', () => { + expect(toEndpoint('/llms.txt')).toBe('/llms.txt') + expect(toEndpoint('/robots.txt')).toBe('/robots.txt') + expect(toEndpoint('/sitemap.xml')).toBe('/sitemap.xml') + expect(toEndpoint('/og')).toBe('/og') + }) + + test('versioned docs map to /docs/:slug', () => { + expect(toEndpoint('/v1/docs/intro')).toBe('/docs/:slug') + expect(toEndpoint('/v2/guides/setup')).toBe('/docs/:slug') + }) +}) diff --git a/packages/chronicle/src/server/plugins/telemetry.ts b/packages/chronicle/src/server/plugins/telemetry.ts index 7f31d5fd..450ef4d8 100644 --- a/packages/chronicle/src/server/plugins/telemetry.ts +++ b/packages/chronicle/src/server/plugins/telemetry.ts @@ -13,6 +13,33 @@ declare module 'nitro/types' { } } +const ROUTES = { + ROOT: '/', + DOCS: '/docs/:slug', + API_INTERNAL: '/api/:action', + API_REFERENCE: '/apis/:slug', + ASSETS: '/assets/:file', + CONTENT: '/_content/:path', +} as const + +const ENDPOINT_MAP: [string, string | null][] = [ + ['/api/', null], + ['/_content/', ROUTES.CONTENT], + ['/apis/', ROUTES.API_REFERENCE], + ['/assets/', ROUTES.ASSETS], +] + +const STATIC_ROUTES = new Set(['/llms.txt', '/robots.txt', '/sitemap.xml', '/og']) + +export function toEndpoint(pathname: string): string { + if (pathname === '/') return ROUTES.ROOT; + for (const [prefix, template] of ENDPOINT_MAP) { + if (pathname.startsWith(prefix)) return template ?? pathname; + } + if (STATIC_ROUTES.has(pathname)) return pathname; + return ROUTES.DOCS; +} + export default definePlugin((nitroApp) => { const config = loadConfig() if (!config.telemetry?.enabled) return @@ -42,7 +69,7 @@ export default definePlugin((nitroApp) => { }) nitroApp.hooks.hook('chronicle:ssr-rendered', (route, status, durationMs) => { - ssrRenderDuration.record(durationMs, { route, status }) + ssrRenderDuration.record(durationMs, { route: toEndpoint(route), status }) }) nitroApp.hooks.hook('request', (event) => { @@ -54,8 +81,8 @@ export default definePlugin((nitroApp) => { 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 }) + const endpoint = toEndpoint(new URL(event.req.url).pathname) + requestCounter.add(1, { method, endpoint, status: res.status }) + requestDuration.record(duration, { method, endpoint, status: res.status }) }) })