diff --git a/public/_headers b/public/_headers index 2ddbdf0..89b525f 100644 --- a/public/_headers +++ b/public/_headers @@ -6,4 +6,10 @@ Strict-Transport-Security: max-age=63072000; includeSubDomains; preload / - Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800 \ No newline at end of file + Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800 + +/_astro/* + Cache-Control: public, max-age=31536000, immutable + +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/src/lib/worker-env.ts b/src/lib/worker-env.ts index 89c15af..f0f507d 100644 --- a/src/lib/worker-env.ts +++ b/src/lib/worker-env.ts @@ -10,6 +10,7 @@ export type WorkerEnv = { AUTH0_MCP_AUDIENCE?: string; NODE_ENV?: string; APP_HEALTH_INGEST_KEY?: string; + APP_HEALTH_STAGE_SAMPLE_RATE?: string; APP_HEALTH_ENVIRONMENT?: string; ASSETS: Fetcher; }; diff --git a/src/worker.test.ts b/src/worker.test.ts index ecea5fd..bd2c00b 100644 --- a/src/worker.test.ts +++ b/src/worker.test.ts @@ -11,6 +11,8 @@ vi.mock('./lib/auth', () => ({ createAuth: () => ({ handler: mocks.auth }) })); vi.mock('./lib/auth-api', () => ({ getAuthenticatedUserId: mocks.user })); vi.mock('./worker/bind-env', () => ({ bindWorkerEnv: vi.fn() })); vi.mock('./worker/app-health', () => ({ + trackAppHealthRequest: vi.fn(), + appHealthStageTimingMiddleware: async (_context: unknown, next: () => Promise) => next(), appHealthMiddleware: async (_context: unknown, next: () => Promise) => { mocks.appHealth(); await next(); diff --git a/src/worker.ts b/src/worker.ts index acc6a1f..213a6b7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -9,7 +9,11 @@ import articlesRoutes from './worker/routes/articles'; import keysRoutes from './worker/routes/keys'; import mcpRoutes, { protectedResourceMetadata } from './worker/routes/mcp'; import miscRoutes from './worker/routes/misc'; -import { appHealthMiddleware } from './worker/app-health'; +import { + appHealthMiddleware, + appHealthStageTimingMiddleware, + trackAppHealthRequest, +} from './worker/app-health'; const SECURITY_HEADERS: Record = { 'X-Content-Type-Options': 'nosniff', @@ -35,6 +39,10 @@ api.use('*', async (c, next) => { }); api.use('*', appHealthMiddleware); +// Hono's /prefix/* also matches /prefix, so register links only once. +for (const path of ['/api/links/*', '/api/articles/*', '/api/mcp/*', '/api/keys/*']) { + api.use(path, appHealthStageTimingMiddleware); +} api.on(['GET', 'HEAD'], '*', async (c, next) => { const path = c.req.path.replace(/\/{2,}/g, '/').replace(/\/+$/, ''); @@ -115,6 +123,7 @@ function withSecurityHeaders(response: Response, pathname: string): Response { export default { async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise { + trackAppHealthRequest(request); const url = new URL(request.url); // Discovery outside /api/* remains separate from product API routes. diff --git a/src/worker/app-health.test.ts b/src/worker/app-health.test.ts index f649e12..3edc7fa 100644 --- a/src/worker/app-health.test.ts +++ b/src/worker/app-health.test.ts @@ -2,13 +2,13 @@ import { Hono } from 'hono'; import { describe, expect, it, vi } from 'vitest'; import type { WorkerEnv } from '../lib/worker-env'; -import { createReaderAppHealthMiddleware } from './app-health'; +import { createReaderAppHealthMiddlewares } from './app-health'; describe('Reader App Health endpoint middleware', () => { it('is disabled without an ingest key', async () => { const createClient = vi.fn(); const app = new Hono<{ Bindings: WorkerEnv }>(); - app.use('*', createReaderAppHealthMiddleware(createClient)); + app.use('*', createReaderAppHealthMiddlewares(createClient).appHealthMiddleware); app.get('/api/articles/:articleId', (c) => c.json({ ok: true })); const response = await app.request('/api/articles/private-id?search=secret', {}, {}); @@ -23,7 +23,7 @@ describe('Reader App Health endpoint middleware', () => { const waitUntil = vi.fn(); const createClient = vi.fn(() => ({ record, flush }) as never); const app = new Hono<{ Bindings: WorkerEnv }>(); - app.use('*', createReaderAppHealthMiddleware(createClient)); + app.use('*', createReaderAppHealthMiddlewares(createClient).appHealthMiddleware); app.on(['GET', 'POST'], '/api/articles/:articleId', (c) => c.json({ content: 'private article body' }) ); @@ -80,3 +80,125 @@ describe('Reader App Health endpoint middleware', () => { expect(JSON.stringify(record.mock.calls[1][0])).not.toContain('another-private-query'); }); }); + +describe('Reader App Health stage timing', () => { + function setup() { + const log = vi.fn(); + const record = vi.fn(); + const flush = vi.fn().mockResolvedValue(undefined); + const createClient = vi.fn(() => ({ log, record, flush }) as never); + const middleware = createReaderAppHealthMiddlewares(createClient); + const app = new Hono<{ Bindings: WorkerEnv }>(); + app.use('*', async (c, next) => { + middleware.trackRequest(c.req.raw); + await next(); + }); + app.use('*', middleware.appHealthMiddleware); + for (const path of ['/api/links/*', '/api/articles/*', '/api/mcp/*', '/api/keys/*']) { + app.use(path, middleware.stageTimingMiddleware); + } + app.get('/api/links', (c) => c.json({ ok: true })); + app.get('/api/articles/:articleId', (c) => c.json({ ok: true })); + app.get('/landing', (c) => c.text('landing')); + const env = { + APP_HEALTH_INGEST_KEY: 'synthetic-test-key', + APP_HEALTH_STAGE_SAMPLE_RATE: '1', + } as WorkerEnv; + return { app, log, flush, createClient, env }; + } + + it('logs one bounded template record and shares the cached client', async () => { + const { app, log, flush, createClient, env } = setup(); + const waitUntil = vi.fn(); + const response = await app.request('/api/articles/123?secret=query', {}, env, { + waitUntil, + } as unknown as ExecutionContext); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(log).toHaveBeenCalledOnce(); + expect(log).toHaveBeenCalledWith('api.stage_timing', { + level: 'debug', + props: { + route: '/api/articles/:articleId', + status: 200, + total_ms: expect.any(Number), + edge_cache: 'NONE', + inner_cache: 'NONE', + colo: 'unknown', + cold: 1, + }, + }); + const props = log.mock.calls[0][1].props; + expect(Number.isInteger(props.total_ms)).toBe(true); + expect(props.total_ms).toBeGreaterThanOrEqual(0); + expect(props.total_ms).toBeLessThanOrEqual(600000); + expect(createClient).toHaveBeenCalledOnce(); + expect(flush).toHaveBeenCalledTimes(2); + expect(waitUntil).toHaveBeenCalledTimes(2); + await app.request('/api/links', {}, env); + expect(log).toHaveBeenCalledTimes(2); + expect(log.mock.calls[1][1].props).toMatchObject({ route: '/api/links', cold: 0 }); + expect(createClient).toHaveBeenCalledOnce(); + }); + + it.each(['0', undefined])('does not log at rate zero or without a key (%s)', async (rate) => { + const { app, log, env } = setup(); + if (rate === undefined) delete env.APP_HEALTH_INGEST_KEY; + else env.APP_HEALTH_STAGE_SAMPLE_RATE = rate; + expect((await app.request('/api/links', {}, env)).status).toBe(200); + expect(log).not.toHaveBeenCalled(); + }); + + it.each(['invalid', '-1', '2', '', undefined])( + 'defaults invalid/missing rate %s to 0.1', + async (rate) => { + const { app, log, env } = setup(); + env.APP_HEALTH_STAGE_SAMPLE_RATE = rate; + const random = vi.spyOn(Math, 'random').mockReturnValue(0.1); + try { + await app.request('/api/links', {}, env); + expect(log).not.toHaveBeenCalled(); + random.mockReturnValue(0.09); + await app.request('/api/links', {}, env); + expect(log).toHaveBeenCalledOnce(); + } finally { + random.mockRestore(); + } + } + ); + + it.each([ + '/api/links/123', + '/api/links/0123456789abcdef', + '/api/links/12345678-abcd-1234-abcd-123456789012', + `/api/links/${'x'.repeat(121)}`, + ])('rejects unsafe route %s', async (route) => { + const { app, log, env } = setup(); + app.get(route, (c) => c.text('ok')); + expect((await app.request(route, {}, env)).status).toBe(200); + expect(log).not.toHaveBeenCalled(); + }); + + it('counts an earlier uninstrumented request as warm', async () => { + const { app, log, env } = setup(); + await app.request('/landing', {}, env); + expect(log).not.toHaveBeenCalled(); + await app.request('/api/links', {}, env); + expect(log.mock.calls[0][1].props.cold).toBe(0); + }); + + it.each(['create', 'log', 'flush', 'reject'])( + 'client %s errors preserve the response', + async (failure) => { + const { app, log, flush, createClient, env } = setup(); + const fail = () => { + throw new Error('telemetry unavailable'); + }; + if (failure === 'create') createClient.mockImplementation(fail); + if (failure === 'log') log.mockImplementation(fail); + if (failure === 'flush') flush.mockImplementation(fail); + if (failure === 'reject') flush.mockRejectedValue(new Error('telemetry unavailable')); + expect((await app.request('/api/links', {}, env)).status).toBe(200); + } + ); +}); diff --git a/src/worker/app-health.ts b/src/worker/app-health.ts index 57fe692..1c16710 100644 --- a/src/worker/app-health.ts +++ b/src/worker/app-health.ts @@ -4,42 +4,111 @@ import { type AppHealthClientOptions, } from '@saas-maker/app-health'; import { honoMiddleware } from '@saas-maker/app-health/hono'; -import type { Context, Env } from 'hono'; +import type { Context, Env, MiddlewareHandler } from 'hono'; +import { routePath } from 'hono/route'; import type { WorkerEnv } from '../lib/worker-env'; type ReaderEnv = Env & { Bindings: WorkerEnv }; type ClientFactory = (options: AppHealthClientOptions) => AppHealthClient; -export function createReaderAppHealthMiddleware( +function stageSampleRate(raw: string | undefined): number { + const parsed = raw?.trim() ? Number(raw.trim()) : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0.1; +} + +function isSafeStageRoute(route: string): boolean { + return ( + route.startsWith('/') && + route.length <= 120 && + !route + .split('/') + .some((segment) => + /^(?:\d+|[a-f0-9]{16,}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test( + segment + ) + ) + ); +} + +export function createReaderAppHealthMiddlewares( createClient: ClientFactory = createAppHealthClient ) { let cachedKey: string | null = null; let cachedEnvironment = ''; let cachedClient: AppHealthClient | null = null; - return honoMiddleware({ - client: (context: Context) => { - const key = context.env.APP_HEALTH_INGEST_KEY?.trim(); - if (!key) return null; + const resolveClient = (context: Context) => { + const key = context.env.APP_HEALTH_INGEST_KEY?.trim(); + if (!key) return null; - const environment = context.env.APP_HEALTH_ENVIRONMENT?.trim() || 'production'; - if (cachedClient && cachedKey === key && cachedEnvironment === environment) { - return cachedClient; - } + const environment = context.env.APP_HEALTH_ENVIRONMENT?.trim() || 'production'; + if (cachedClient && cachedKey === key && cachedEnvironment === environment) { + return cachedClient; + } + + cachedKey = key; + cachedEnvironment = environment; + cachedClient = createClient({ + key, + environment, + endpoint: 'https://ingest.sassmaker.com/v1/ingest', + runtime: 'worker', + disableTimer: true, + }); + return cachedClient; + }; + let firstRequest = true; + const coldRequests = new WeakSet(); + const trackRequest = (request: Request) => { + if (firstRequest) coldRequests.add(request); + firstRequest = false; + }; - cachedKey = key; - cachedEnvironment = environment; - cachedClient = createClient({ - key, - environment, - endpoint: 'https://ingest.sassmaker.com/v1/ingest', - runtime: 'worker', - disableTimer: true, + const stageTimingMiddleware: MiddlewareHandler = async (c, next) => { + const start = performance.now(); + await next(); + // Telemetry must never change the response or expose request data. + try { + const rate = stageSampleRate(c.env.APP_HEALTH_STAGE_SAMPLE_RATE); + if (!c.env.APP_HEALTH_INGEST_KEY?.trim() || rate === 0 || Math.random() >= rate) return; + const route = routePath(c, -1); + if (!isSafeStageRoute(route)) return; + const client = resolveClient(c); + if (!client) return; + const colo = c.req.raw.cf?.colo; + client.log('api.stage_timing', { + level: 'debug', + props: { + route, + status: c.res.status, + total_ms: Math.max(0, Math.min(600000, Math.round(performance.now() - start))), + edge_cache: 'NONE', + inner_cache: 'NONE', + colo: typeof colo === 'string' && /^[A-Za-z0-9]{1,8}$/.test(colo) ? colo : 'unknown', + cold: coldRequests.has(c.req.raw) ? 1 : 0, + }, }); - return cachedClient; - }, - }); + const delivery = client.flush().catch(() => {}); + try { + c.executionCtx.waitUntil(delivery); + } catch { + // Local requests may have no execution context; delivery is already guarded. + } + } catch { + // Client creation, logging, and synchronous flush failures are fail-open. + } + }; + + return { + appHealthMiddleware: honoMiddleware({ client: resolveClient }), + stageTimingMiddleware, + trackRequest, + }; } -export const appHealthMiddleware = createReaderAppHealthMiddleware(); +export const { + appHealthMiddleware, + stageTimingMiddleware: appHealthStageTimingMiddleware, + trackRequest: trackAppHealthRequest, +} = createReaderAppHealthMiddlewares(); diff --git a/wrangler.toml b/wrangler.toml index f115bbd..41368ba 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -4,7 +4,7 @@ compatibility_date = "2025-04-01" compatibility_flags = ["nodejs_compat_v2"] assets = { directory = "dist", binding = "ASSETS", run_worker_first = [ - "/*"] } + "/*", "!/_astro/*", "!/assets/*", "!/images/*", "!/footer-art/*"] } routes = [ { pattern = "read.significanthobbies.com", custom_domain = true }