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
3 changes: 3 additions & 0 deletions docs/chronicle.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ search:
llms:
enabled: true

telemetry:
enabled: true

footer:
copyright: "© 2026 Raystack. All rights reserved."
links:
Expand Down
24 changes: 24 additions & 0 deletions docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ analytics:
enabled: true
googleAnalytics:
measurementId: G-XXXXXXXXXX

telemetry:
enabled: true
serviceName: my-docs
port: 9090
```

## Reference
Expand Down Expand Up @@ -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` |
Comment on lines +286 to +290
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

Document the validated telemetry.port constraint.

The table says number, but the implementation validates an integer in 1-65535. Please mirror that here so users do not hit startup validation errors after copying the example.

Proposed fix
-| `port` | `number` | Port for Prometheus metrics endpoint | `9090` |
+| `port` | `integer` | Port for Prometheus metrics endpoint. Must be in `1-65535`. | `9090` |
📝 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
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `enabled` | `boolean` | Enable/disable telemetry | `false` |
| `serviceName` | `string` | OpenTelemetry service name | `chronicle` |
| `port` | `number` | Port for Prometheus metrics endpoint | `9090` |
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `enabled` | `boolean` | Enable/disable telemetry | `false` |
| `serviceName` | `string` | OpenTelemetry service name | `chronicle` |
| `port` | `integer` | Port for Prometheus metrics endpoint. Must be in `1-65535`. | `9090` |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/configuration.mdx` around lines 286 - 290, Update the docs table to
document the validated constraint for telemetry.port: change the Description for
`port` (telemetry.port) to indicate it must be an integer in the range 1–65535
(e.g., "Port for Prometheus metrics endpoint; integer 1-65535") so it matches
the runtime validation and avoids startup errors when users copy the example.


Metrics are available at `http://localhost:<port>/metrics` in Prometheus exposition format.

## Defaults

If `chronicle.yaml` is missing or fields are omitted, these defaults apply:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
40 changes: 34 additions & 6 deletions packages/chronicle/src/lib/page-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface PageContextValue {
config: ChronicleConfig;
tree: Root;
page: PageData | null;
errorStatus: number | null;
apiSpecs: ApiSpec[];
}

Expand All @@ -35,6 +36,7 @@ export function usePageContext(): PageContextValue {
config: { title: 'Documentation' },
tree: { name: 'root', children: [] } as Root,
page: null,
errorStatus: null,
apiSpecs: []
};
}
Expand All @@ -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,
Expand All @@ -61,6 +73,7 @@ export function PageProvider({
const { pathname } = useLocation();
const [tree] = useState<Root>(initialTree);
const [page, setPage] = useState<PageData | null>(initialPage);
const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, pathname));
const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
const [currentPath, setCurrentPath] = useState(pathname);

Expand All @@ -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())
Expand All @@ -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 (
<PageContext.Provider
value={{ config: initialConfig, tree, page, apiSpecs }}
value={{ config: initialConfig, tree, page, errorStatus, apiSpecs }}
>
{children}
</PageContext.Provider>
Expand Down
5 changes: 4 additions & 1 deletion packages/chronicle/src/pages/DocsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { Head } from '@/lib/head';
import { usePageContext } from '@/lib/page-context';
import { NotFound } from '@/pages/NotFound';
import { getTheme } from '@/themes/registry';

interface DocsPageProps {
slug: string[];
}

export function DocsPage({ slug }: DocsPageProps) {
const { config, tree, page } = usePageContext();
const { config, tree, page, errorStatus } = usePageContext();

if (errorStatus === 404) return <NotFound />;
if (errorStatus) return <NotFound />;
if (!page) return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { Page } = getTheme(config.theme?.name);
Expand Down
3 changes: 3 additions & 0 deletions packages/chronicle/src/pages/NotFound.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.emptyState {
justify-content: center;
}
19 changes: 7 additions & 12 deletions packages/chronicle/src/pages/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
direction='column'
align='center'
justify='center'
style={{ minHeight: '60vh' }}
>
<Headline size='large' as='h1'>
404
</Headline>
<Text size={3}>Page not found</Text>
</Flex>
<EmptyState
heading="404"
subHeading="Page not found"
classNames={{ container: styles.emptyState }}
/>
);
}
23 changes: 0 additions & 23 deletions packages/chronicle/src/server/api/metrics.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/chronicle/src/server/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
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

🧩 Analysis chain

🌐 Web query:

In Nitro/Hookable, does hooks.callHook(...) return a Promise that should be awaited or otherwise handled to avoid unhandled rejections?

💡 Result:

Yes, in Nitro/Hookable, hooks.callHook(...) returns a Promise that should be awaited or otherwise handled to avoid unhandled rejections if any hook callbacks reject.

Citations:


🏁 Script executed:

# Find and examine the entry-server.tsx file
fd -t f entry-server.tsx

Repository: raystack/chronicle

Length of output: 108


🏁 Script executed:

# Read the file around line 101 to understand context
head -n 120 packages/chronicle/src/server/entry-server.tsx | tail -n 40

Repository: raystack/chronicle

Length of output: 1369


🏁 Script executed:

# Get more context - full function containing line 101
sed -n '80,120p' packages/chronicle/src/server/entry-server.tsx

Repository: raystack/chronicle

Length of output: 929


🏁 Script executed:

# Get the full function signature and start
sed -n '50,110p' packages/chronicle/src/server/entry-server.tsx

Repository: raystack/chronicle

Length of output: 2003


🏁 Script executed:

# Check if the handler function is async
sed -n '40,105p' packages/chronicle/src/server/entry-server.tsx

Repository: raystack/chronicle

Length of output: 2306


🏁 Script executed:

# Get the complete handler function definition from start to line 110
sed -n '1,110p' packages/chronicle/src/server/entry-server.tsx | head -n 50

Repository: raystack/chronicle

Length of output: 1897


🏁 Script executed:

# Search for the handler function signature
rg -A 80 "export default" packages/chronicle/src/server/entry-server.tsx | head -n 100

Repository: raystack/chronicle

Length of output: 2745


🏁 Script executed:

# Check the telemetry hook registration to understand if it's meant to be fire-and-forget
cat packages/chronicle/src/server/plugins/telemetry.ts

Repository: raystack/chronicle

Length of output: 2247


🏁 Script executed:

# Look for other usages of callHook in the codebase to see if there's a pattern
rg "callHook" packages/chronicle/src/server/

Repository: raystack/chronicle

Length of output: 202


Handle the Nitro hook promise explicitly.

callHook(...) is async and returns a Promise. Since the fetch handler is async, either await it here or intentionally detach it with void ... .catch(...) to avoid unhandled promise rejection.

Proposed fix
-    useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
+    await useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
📝 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
useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
await useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/entry-server.tsx` at line 101, The call to
useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status,
renderDuration) returns a Promise and must be handled to avoid unhandled
rejections; update the code in the entry-server SSR path to either await the
call (e.g., await useNitroApp().hooks.callHook(...)) or explicitly detach it
with void useNitroApp().hooks.callHook(...).catch(err => /* log or ignore */) so
errors are caught; refer to useNitroApp, hooks.callHook and the
'chronicle:ssr-rendered' invocation with pathname, status, renderDuration to
locate and fix the line.


return new Response(stream, {
status,
Expand Down
54 changes: 47 additions & 7 deletions packages/chronicle/src/server/plugins/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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] })
Comment on lines +24 to +26
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 | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does @opentelemetry/exporter-prometheus start a standalone Node.js HTTP server, and is that supported in edge/serverless runtimes like Cloudflare Workers or Vercel?

💡 Result:

Yes, @opentelemetry/exporter-prometheus starts a standalone Node.js HTTP server by default on port 9464 to expose metrics for Prometheus scraping, as shown in official documentation and examples where instantiation with { port: 9464 } enables access at http://localhost:9464/metrics. This can be prevented using the preventServerStart: true option, allowing manual control via exporter.getMetricsRequestHandler(req, res) to integrate into an existing server. However, it is not supported in edge/serverless runtimes like Cloudflare Workers or Vercel Edge Functions. Cloudflare Workers (V8 isolates) fail to import due to Node.js "http" module dependencies, even with preventServerStart: true and node_compat=true (errors: "Could not resolve 'http'", "globalThis.XMLHttpRequest is not a constructor"). Vercel documentation focuses on tracing (OTLP), with no Prometheus exporter mention; Edge runtime custom spans unsupported. Use OTLP/HTTP exporters or platform-native observability instead.

Citations:


🏁 Script executed:

cat -n packages/chronicle/src/server/plugins/telemetry.ts | head -50

Repository: raystack/chronicle

Length of output: 2230


🏁 Script executed:

rg -i "preset|vercel|cloudflare|node-server" packages/chronicle --type ts --type js | head -20

Repository: raystack/chronicle

Length of output: 1920


🏁 Script executed:

cat -n packages/chronicle/src/lib/config.ts | head -100

Repository: raystack/chronicle

Length of output: 1222


🏁 Script executed:

cat -n packages/chronicle/src/types/config.ts

Repository: raystack/chronicle

Length of output: 3802


Guard this exporter to supported runtimes or gate telemetry to node-server deployments.

PrometheusExporter starts a standalone HTTP listener by default. This works for long-lived node-server deployments but will fail in edge/serverless environments like Vercel or Cloudflare Workers, which cannot expose arbitrary ports. The plugin currently has no runtime detection to prevent initialization on incompatible presets. Either:

  • Gate the plugin to node-server preset only, or
  • Use preventServerStart: true and manually integrate metrics into the existing HTTP server, or
  • Document that telemetry is only supported for node-server deployment

Without a guard, enabling telemetry.enabled: true in config will cause deployments to fail on unsupported runtimes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/plugins/telemetry.ts` around lines 23 - 25, The
PrometheusExporter is started unguarded (const exporter = new
PrometheusExporter({ port })) which will spawn an HTTP listener and break
edge/serverless runtimes; update the telemetry init in this plugin to only
initialize/exporter when running in a node-server preset or, alternatively,
construct PrometheusExporter with preventServerStart: true and skip starting its
server so metrics can be mounted into the existing HTTP server. Concretely:
check the runtime/preset or config (e.g., config.telemetry and deployment/preset
check) before creating PrometheusExporter, or instantiate new
PrometheusExporter({ port, preventServerStart: true }) and document that manual
integration into the HTTP server is required; ensure
MeterProvider/resource/readers setup only proceeds after that guard.

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()
Comment on lines +39 to +41
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

🧩 Analysis chain

🌐 Web query:

In OpenTelemetry JS, does MeterProvider.shutdown()already shut down registered readers such asPrometheusExporter, making an extra exporter.shutdown() redundant?

💡 Result:

Yes, MeterProvider.shutdown shuts down registered readers such as PrometheusExporter, making an extra exporter.shutdown redundant.

Citations:


🏁 Script executed:

cat -n packages/chronicle/src/server/plugins/telemetry.ts

Repository: raystack/chronicle

Length of output: 2646


Remove the redundant exporter.shutdown() call.

The exporter is registered as a reader when the MeterProvider is created on line 25. When provider.shutdown() is called, it automatically shuts down all registered readers, including the exporter. Calling exporter.shutdown() again is redundant and can cause issues during process shutdown.

Proposed fix
  nitroApp.hooks.hook('close', async () => {
    await provider.shutdown()
-   await exporter.shutdown()
  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/plugins/telemetry.ts` around lines 38 - 40, The
shutdown hook currently calls both provider.shutdown() and exporter.shutdown();
remove the redundant exporter.shutdown() call so that only provider.shutdown()
is invoked in the nitroApp.hooks.hook('close', ...) callback, since the
MeterProvider (provider) will already shut down all registered readers including
the exporter.

})

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 })
})
Comment on lines 52 to 60
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that event.req.url in Nitro/H3 is a relative path
# Search for how other Nitro plugins or H3 middleware handle URL parsing

rg -n -C3 'event\.req\.url|getRequestURL' --type ts packages/chronicle/src/server

Repository: raystack/chronicle

Length of output: 1387


🏁 Script executed:

#!/bin/bash
# Search for usage of event.path or other URL handling patterns in H3/Nitro plugins
rg -n 'event\.path|getRequestURL|event\.node\.req' --type ts packages/chronicle/src/server/plugins

Repository: raystack/chronicle

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Check what properties are available on H3Event by looking at imports and type usage
rg -n 'H3Event|defineEventHandler|import.*h3' --type ts packages/chronicle/src/server | head -40

Repository: raystack/chronicle

Length of output: 402


🌐 Web query:

H3 H3Event event.req.url relative path absolute pathname

💡 Result:

In H3 (the high-performance HTTP framework used by Nuxt/Nitro), an H3Event object is created for each incoming HTTP request. The event.req property provides access to the incoming HTTP request information, modeled after the native Web API Request with runtime addons. event.req.url is a string containing the absolute URL of the request (full URL including protocol, host, port, pathname, search, and hash if present). This matches the standard behavior of the Fetch API's Request.url property, which always returns the absolute URL. However, in server environments like Node.js, H3 constructs event.req such that event.req.url typically holds the relative path (pathname + search) from the request URL, without the origin (e.g., "/api/users?id=123" for a request to "https://example.com/api/users?id=123"). This is consistent with traditional Node.js http.IncomingMessage.url and common server framework conventions, allowing logging like [GET] /api/users. To get the relative path (just pathname + search): - Use event.req.url directly (e.g., "/api/users?id=123") - Or prefer event.path, which is explicitly the request path (same value, recommended over parsing event.req.url) To get the absolute pathname (full URL as string): - Use getRequestURL(event) utility (e.g., "https://example.com/api/users?id=123") Examples from official docs: app.get("/", async (event) => { console.log(event.req.url); // "/" const fullUrl = getRequestURL(event); // "https://example.com/" }); event.path // "/test?test=123" (relative path example) event.url provides a parsed URL object for the full request URL. Sources confirm event.req is Web Request-based but adapted for server use, with event.path preferred for relative paths post-refactors.

Citations:


🏁 Script executed:

#!/bin/bash
# Check if there are tests for telemetry plugin
fd -e test.ts -e spec.ts -e test.js packages/chronicle | grep -i telemetry

# Also check test directories
find packages/chronicle -type f -name "*test*" -o -name "*spec*" | grep -i telemetry

Repository: raystack/chronicle

Length of output: 439


🏁 Script executed:

#!/bin/bash
# Check if event.path is used elsewhere in the codebase for URL parsing
rg -n 'event\.path' --type ts packages/chronicle/src/server

Repository: raystack/chronicle

Length of output: 185


new URL(event.req.url) will throw for relative paths and crash telemetry.

In H3/Nitro, event.req.url is a relative path (e.g., /api/users?query=1), not an absolute URL. Calling new URL('/api/users') without a base throws TypeError: Invalid URL, which will crash every response and disable telemetry entirely.

Use event.path instead, which H3 provides specifically for this purpose:

   nitroApp.hooks.hook('response', (res, event) => {
     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
+    const route = event.path.split('?')[0]
     requestCounter.add(1, { method, route, status: res.status })
     requestDuration.record(duration, { method, route, status: res.status })
   })

Alternatively, if you need to construct a URL object, use a base: new URL(event.req.url ?? '/', 'http://localhost').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
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 })
})
nitroApp.hooks.hook('response', (res, event) => {
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 = event.path.split('?')[0]
requestCounter.add(1, { method, route, status: res.status })
requestDuration.record(duration, { method, route, status: res.status })
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/plugins/telemetry.ts` around lines 52 - 60, In
the response hook in telemetry.ts (the nitroApp.hooks.hook('response', ...)),
stop using new URL(event.req.url).pathname because event.req.url can be a
relative path and will throw; instead derive the route from the H3 event
directly (use event.path) or, if you must parse, construct a URL with a base
(e.g., new URL(event.req.url ?? '/', 'http://localhost').pathname) before
calling .pathname; update the code that sets the route variable and keep the
rest of the requestCounter/requestDuration calls unchanged.

})
49 changes: 0 additions & 49 deletions packages/chronicle/src/server/telemetry.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/chronicle/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down