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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=$!
Expand Down
47 changes: 47 additions & 0 deletions packages/chronicle/src/server/plugins/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
35 changes: 31 additions & 4 deletions packages/chronicle/src/server/plugins/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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)
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 | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for event.req.url usage patterns in the codebase to determine format

# Search for existing usage of event.req.url
rg -n -C3 'event\.req\.url' --type=ts

# Search for URL parsing patterns
rg -n -C3 'new URL.*event\.req' --type=ts

Repository: raystack/chronicle

Length of output: 2143


🌐 Web query:

Nitro h3 event.req.url format full URL or relative path

💡 Result:

Nitro/H3: event.req.url is a relative URL/path component (not a full absolute URL). In Nitro, you typically access the request URL parts via: - event.url.pathname for the matched pathname (example middleware/routing uses event.url.pathname) [1] - event.req.url is then used as the path-ish value when Nitro/H3 creates the H3 event (and is consumed as such by proxy/middleware expectations); in an H3 bug report, overwriting node req.url with a decoded path is explicitly called out as wrong because it’s “consumed by HTTP proxies and Node.js middleware that expect the percent-encoded form” [2]. That implies req.url holds the (encoded) path/relative portion, not an origin-based full URL. If you need a full absolute URL, Nitro provides getRequestURL(event) (discussed in Nitro issues about origin/hostname extraction) rather than relying on event.req.url [3]. So, the answer to “Nitro h3 event.req.url format full URL or relative path” is: relative path (path + query), not an absolute URL with scheme/host.

Citations:


Fix unsafe new URL(event.req.url) usage in telemetry (can throw).

In Nitro/H3, event.req.url is typically a relative path (path + query), so new URL(event.req.url).pathname can throw TypeError: Invalid URL and break request metrics. This matches the repo’s existing pattern using event.path || event.req.url?.split('?')[0] in packages/chronicle/src/server/routes/[...slug].md.ts.

🔧 Proposed fix
-    const endpoint = toEndpoint(new URL(event.req.url).pathname)
+    const pathname = event.path || event.req.url?.split('?')[0] || '/'
+    const endpoint = toEndpoint(pathname)
📝 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 endpoint = toEndpoint(new URL(event.req.url).pathname)
const pathname = event.path || event.req.url?.split('?')[0] || '/'
const endpoint = toEndpoint(pathname)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/server/plugins/telemetry.ts` at line 75, The code uses
new URL(event.req.url).pathname which can throw for relative URLs; replace that
with a safe extraction like using event.path if available or falling back to
event.req.url?.split('?')[0] before calling toEndpoint. Update the endpoint
assignment in telemetry.ts (the const endpoint = ... line) to derive a safePath
= event.path || event.req.url?.split('?')[0] (or an empty string fallback) and
then call toEndpoint(safePath) so toEndpoint and endpoint are never fed an
invalid URL.

requestCounter.add(1, { method, endpoint, status: res.status })
requestDuration.record(duration, { method, endpoint, status: res.status })
})
})
Loading