@jimhoyd/urlcode 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/CONTRIBUTING.md +80 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +20 -0
  4. package/README.md +342 -0
  5. package/ROADMAP.md +248 -0
  6. package/SECURITY.md +40 -0
  7. package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
  8. package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
  9. package/data/agents/ai-crawlers.json +1240 -0
  10. package/data/agents/crawlers.json +10515 -0
  11. package/data/agents/index.d.ts +3 -0
  12. package/data/agents/index.js +2453 -0
  13. package/data/agents/monitoring.json +1520 -0
  14. package/data/agents/seo.json +3865 -0
  15. package/dist/BUILD-MANIFEST.json +60 -0
  16. package/dist/adapters.js +59 -0
  17. package/dist/agent-lists.js +59 -0
  18. package/dist/assets.js +129 -0
  19. package/dist/authoring.js +68 -0
  20. package/dist/aws.js +110 -0
  21. package/dist/build-cloudflare.js +164 -0
  22. package/dist/cli.js +185 -0
  23. package/dist/client-address.js +85 -0
  24. package/dist/cloudflare.js +165 -0
  25. package/dist/compliance-rules/baseline.js +98 -0
  26. package/dist/compliance-rules/privacy.js +38 -0
  27. package/dist/compliance-rules/shared.js +62 -0
  28. package/dist/compliance-rules/strict.js +61 -0
  29. package/dist/compliance.js +219 -0
  30. package/dist/config-worker.js +9 -0
  31. package/dist/config.js +172 -0
  32. package/dist/errors.js +8 -0
  33. package/dist/function-sources.js +54 -0
  34. package/dist/function-worker.js +105 -0
  35. package/dist/functions.js +174 -0
  36. package/dist/guest-api.js +114 -0
  37. package/dist/header-validation.js +18 -0
  38. package/dist/http-policy.js +74 -0
  39. package/dist/http-response.js +84 -0
  40. package/dist/index.js +11 -0
  41. package/dist/link-api.js +136 -0
  42. package/dist/link-cli.js +141 -0
  43. package/dist/link-events.js +76 -0
  44. package/dist/link-records.js +31 -0
  45. package/dist/link-store-worker.js +150 -0
  46. package/dist/link-store.js +250 -0
  47. package/dist/logging.js +22 -0
  48. package/dist/management-policy.js +41 -0
  49. package/dist/match.js +124 -0
  50. package/dist/observability.js +242 -0
  51. package/dist/plugins.js +74 -0
  52. package/dist/policies/agents.js +248 -0
  53. package/dist/policies/cache.js +297 -0
  54. package/dist/policies/compression.js +187 -0
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies/throttle.js +131 -0
  57. package/dist/policies.js +142 -0
  58. package/dist/policy.js +55 -0
  59. package/dist/prerender.js +165 -0
  60. package/dist/project-tests.js +23 -0
  61. package/dist/readiness.js +216 -0
  62. package/dist/router.js +166 -0
  63. package/dist/runtime.js +244 -0
  64. package/dist/scaffold.js +0 -0
  65. package/dist/scripts/operational-drills.js +75 -0
  66. package/dist/server.js +270 -0
  67. package/dist/site.js +0 -0
  68. package/dist/sqlite-version.js +6 -0
  69. package/dist/types/adapters.d.ts +12 -0
  70. package/dist/types/agent-lists.d.ts +5 -0
  71. package/dist/types/assets.d.ts +13 -0
  72. package/dist/types/authoring.d.ts +2 -0
  73. package/dist/types/aws.d.ts +35 -0
  74. package/dist/types/build-cloudflare.d.ts +14 -0
  75. package/dist/types/cli.d.ts +2 -0
  76. package/dist/types/client-address.d.ts +9 -0
  77. package/dist/types/cloudflare.d.ts +75 -0
  78. package/dist/types/compliance-rules/baseline.d.ts +12 -0
  79. package/dist/types/compliance-rules/privacy.d.ts +6 -0
  80. package/dist/types/compliance-rules/shared.d.ts +24 -0
  81. package/dist/types/compliance-rules/strict.d.ts +9 -0
  82. package/dist/types/compliance.d.ts +119 -0
  83. package/dist/types/config-worker.d.ts +1 -0
  84. package/dist/types/config.d.ts +21 -0
  85. package/dist/types/errors.d.ts +7 -0
  86. package/dist/types/function-sources.d.ts +21 -0
  87. package/dist/types/function-worker.d.ts +1 -0
  88. package/dist/types/functions.d.ts +92 -0
  89. package/dist/types/guest-api.d.ts +16 -0
  90. package/dist/types/header-validation.d.ts +2 -0
  91. package/dist/types/http-policy.d.ts +39 -0
  92. package/dist/types/http-response.d.ts +43 -0
  93. package/dist/types/index.d.ts +11 -0
  94. package/dist/types/link-api.d.ts +30 -0
  95. package/dist/types/link-cli.d.ts +36 -0
  96. package/dist/types/link-events.d.ts +27 -0
  97. package/dist/types/link-records.d.ts +11 -0
  98. package/dist/types/link-store-worker.d.ts +1 -0
  99. package/dist/types/link-store.d.ts +130 -0
  100. package/dist/types/logging.d.ts +9 -0
  101. package/dist/types/management-policy.d.ts +9 -0
  102. package/dist/types/match.d.ts +81 -0
  103. package/dist/types/observability.d.ts +104 -0
  104. package/dist/types/plugins.d.ts +27 -0
  105. package/dist/types/policies/agents.d.ts +86 -0
  106. package/dist/types/policies/cache.d.ts +82 -0
  107. package/dist/types/policies/compression.d.ts +41 -0
  108. package/dist/types/policies/security.d.ts +37 -0
  109. package/dist/types/policies/throttle.d.ts +55 -0
  110. package/dist/types/policies.d.ts +36 -0
  111. package/dist/types/policy.d.ts +21 -0
  112. package/dist/types/prerender.d.ts +31 -0
  113. package/dist/types/project-tests.d.ts +13 -0
  114. package/dist/types/readiness.d.ts +121 -0
  115. package/dist/types/router.d.ts +11 -0
  116. package/dist/types/runtime.d.ts +91 -0
  117. package/dist/types/scaffold.d.ts +17 -0
  118. package/dist/types/server.d.ts +37 -0
  119. package/dist/types/site.d.ts +12 -0
  120. package/dist/types/sqlite-version.d.ts +1 -0
  121. package/dist/types/types.d.ts +332 -0
  122. package/dist/types/vercel.d.ts +12 -0
  123. package/dist/types.js +177 -0
  124. package/dist/vercel.js +65 -0
  125. package/docs/AI-AUTHORING.md +113 -0
  126. package/docs/ASSETS.md +106 -0
  127. package/docs/AWS.md +77 -0
  128. package/docs/BEST-PRACTICES.md +267 -0
  129. package/docs/CAPACITY.md +208 -0
  130. package/docs/CLOUDFLARE.md +110 -0
  131. package/docs/COMPLIANCE.md +242 -0
  132. package/docs/DYNAMIC-LINKS.md +561 -0
  133. package/docs/FUNCTION-SECURITY.md +113 -0
  134. package/docs/HTTP.md +129 -0
  135. package/docs/INSTALL.md +90 -0
  136. package/docs/LOAD-TESTING.md +91 -0
  137. package/docs/LOCAL-DEVELOPMENT.md +99 -0
  138. package/docs/MANAGEMENT-SECURITY.md +82 -0
  139. package/docs/MIDDLEWARE.md +83 -0
  140. package/docs/MONITORING.md +132 -0
  141. package/docs/OBSERVABILITY.md +229 -0
  142. package/docs/OPERATIONAL-PROOF.md +42 -0
  143. package/docs/OPERATIONS.md +207 -0
  144. package/docs/ORGANIZATION.md +135 -0
  145. package/docs/PERFORMANCE.md +72 -0
  146. package/docs/PLUGINS.md +234 -0
  147. package/docs/POLICIES.md +489 -0
  148. package/docs/PRERENDER.md +193 -0
  149. package/docs/PROJECT-DIRECTION.md +51 -0
  150. package/docs/READINESS.md +125 -0
  151. package/docs/README.md +58 -0
  152. package/docs/RELEASE-READINESS.md +95 -0
  153. package/docs/RELEASE-SECURITY.md +72 -0
  154. package/docs/RESILIENCE.md +160 -0
  155. package/docs/ROUTING.md +95 -0
  156. package/docs/SANDBOX-REVIEW.md +59 -0
  157. package/docs/SCAFFOLDING.md +72 -0
  158. package/docs/SECURITY-AUDIT.md +124 -0
  159. package/docs/SITE.md +150 -0
  160. package/docs/SPECIFICATION.md +232 -0
  161. package/docs/SPIKE-EXTENSIONS.md +475 -0
  162. package/docs/STANDARDS.md +303 -0
  163. package/docs/STARTERS.md +51 -0
  164. package/docs/TUNNELS.md +75 -0
  165. package/docs/TYPESCRIPT.md +102 -0
  166. package/docs/VERCEL.md +107 -0
  167. package/docs/YAML-GUIDE.md +521 -0
  168. package/docs/YAML-REFERENCE.md +328 -0
  169. package/docs/policies/agents.md +182 -0
  170. package/docs/policies/cache.md +139 -0
  171. package/docs/policies/compression.md +168 -0
  172. package/docs/policies/security.md +160 -0
  173. package/docs/policies/throttle.md +103 -0
  174. package/examples/assets/.env.example +1 -0
  175. package/examples/assets/.gitattributes +2 -0
  176. package/examples/assets/Makefile +30 -0
  177. package/examples/assets/README.md +18 -0
  178. package/examples/assets/functions/hello.mjs +3 -0
  179. package/examples/assets/gitignore.template +7 -0
  180. package/examples/assets/public/about.html +2 -0
  181. package/examples/assets/public/assets/example.txt +1 -0
  182. package/examples/assets/public/guide.txt +1 -0
  183. package/examples/assets/tests/requests.json +44 -0
  184. package/examples/assets/urlcode.yaml +27 -0
  185. package/examples/aws/README.md +13 -0
  186. package/examples/aws/handler.mjs +5 -0
  187. package/examples/aws/public/index.html +3 -0
  188. package/examples/aws/public/notes.txt +1 -0
  189. package/examples/aws/template.yaml +33 -0
  190. package/examples/aws/tests/requests.json +9 -0
  191. package/examples/aws/urlcode.yaml +19 -0
  192. package/examples/cloudflare/README.md +18 -0
  193. package/examples/cloudflare/package.json +14 -0
  194. package/examples/cloudflare/tests/requests.json +9 -0
  195. package/examples/cloudflare/urlcode.yaml +24 -0
  196. package/examples/cloudflare/wrangler.toml +5 -0
  197. package/examples/compliance/README.md +20 -0
  198. package/examples/compliance/rules.mjs +65 -0
  199. package/examples/cookbook/README.md +21 -0
  200. package/examples/cookbook/functions/choice.mjs +4 -0
  201. package/examples/cookbook/functions/echo.mjs +3 -0
  202. package/examples/cookbook/functions/hello.mjs +3 -0
  203. package/examples/cookbook/functions/text.mjs +3 -0
  204. package/examples/cookbook/middleware/headers.mjs +6 -0
  205. package/examples/cookbook/public/about.html +1 -0
  206. package/examples/cookbook/public/assets/index.html +1 -0
  207. package/examples/cookbook/public/assets/site.css +1 -0
  208. package/examples/cookbook/public/favicon.svg +1 -0
  209. package/examples/cookbook/public/guide.txt +1 -0
  210. package/examples/cookbook/public/llms.txt +6 -0
  211. package/examples/cookbook/routes/code.yaml +55 -0
  212. package/examples/cookbook/routes/files.yaml +17 -0
  213. package/examples/cookbook/routes/policies.yaml +28 -0
  214. package/examples/cookbook/routes/redirects.yaml +38 -0
  215. package/examples/cookbook/routes/responses.yaml +26 -0
  216. package/examples/cookbook/tests/requests.json +254 -0
  217. package/examples/cookbook/urlcode.yaml +22 -0
  218. package/examples/live-links/README.md +11 -0
  219. package/examples/live-links/tests/requests.json +6 -0
  220. package/examples/live-links/urlcode.yaml +16 -0
  221. package/examples/monitoring/blackbox-jobs.yaml +37 -0
  222. package/examples/monitoring/prometheus-rules.yaml +88 -0
  223. package/examples/monitoring/prometheus-scrape.yaml +20 -0
  224. package/examples/monitoring/vector.toml +65 -0
  225. package/examples/prerender/README.md +48 -0
  226. package/examples/prerender/functions/page.mjs +6 -0
  227. package/examples/prerender/middleware/template.mjs +29 -0
  228. package/examples/prerender/prerender.d.mts +4 -0
  229. package/examples/prerender/prerender.mjs +63 -0
  230. package/examples/prerender/tests/requests.json +39 -0
  231. package/examples/prerender/urlcode.yaml +50 -0
  232. package/examples/tunnel/dev-with-ngrok.sh +61 -0
  233. package/examples/vercel/README.md +13 -0
  234. package/examples/vercel/api/index.js +5 -0
  235. package/examples/vercel/package.json +8 -0
  236. package/examples/vercel/public/index.html +3 -0
  237. package/examples/vercel/public/notes.txt +1 -0
  238. package/examples/vercel/tests/requests.json +48 -0
  239. package/examples/vercel/urlcode.yaml +19 -0
  240. package/examples/vercel/vercel.json +10 -0
  241. package/llms.txt +46 -0
  242. package/package.json +123 -0
  243. package/schemas/urlcode.schema.json +1230 -0
  244. package/starters/default/.gitattributes +1 -0
  245. package/starters/default/Makefile +30 -0
  246. package/starters/default/README.md +34 -0
  247. package/starters/default/functions/hello.mjs +3 -0
  248. package/starters/default/gitignore.template +8 -0
  249. package/starters/default/middleware/headers.mjs +6 -0
  250. package/starters/default/routes/functions.yaml +20 -0
  251. package/starters/default/routes/marketing/links.yaml +7 -0
  252. package/starters/default/starter.json +5 -0
  253. package/starters/default/tests/requests.json +56 -0
  254. package/starters/default/urlcode.yaml +8 -0
@@ -0,0 +1,83 @@
1
+ # Middleware
2
+
3
+ Middleware is reusable JavaScript around any route handler. It is optional and
4
+ route-local; plain redirects and assets retain their native fast path when no
5
+ middleware is attached. Adding middleware requires sandbox execution.
6
+
7
+ ```yaml
8
+ version: "1"
9
+ routes:
10
+ /go:
11
+ middleware:
12
+ - source: middleware/headers.mjs
13
+ export: default
14
+ redirect:
15
+ url: https://example.com
16
+ ```
17
+
18
+ ```js
19
+ export default async function headers(request, context, next) {
20
+ context.state.startedAt = Date.now();
21
+ const response = await next();
22
+ response.headers.set('x-example-middleware', 'active');
23
+ return response;
24
+ }
25
+ ```
26
+
27
+ Paths resolve from the project root. Up to 16 entries are allowed; each accepts
28
+ `source` and optional `export` (default `default`). Reuse a module across routes.
29
+ There are no global middleware settings or middleware-specific argument fields.
30
+ This is URLCode's small portable API, not Express/Node middleware compatibility.
31
+
32
+ ## Order and responses
33
+
34
+ For `[first, second]`, execution is first-before, second-before, handler,
35
+ second-after, first-after. Call `await next()` to obtain the downstream response.
36
+ Call it at most once, during the middleware invocation, with no arguments.
37
+ Always return a `Response`. Return early to skip downstream middleware and the
38
+ handler, for example `return new Response('Denied', {status: 403})`.
39
+ Middleware may catch downstream JavaScript exceptions and return a fallback.
40
+
41
+ Every middleware and the function share the same request and context. Validated
42
+ `inputs`, function `args`, and route-scoped `env`/`secrets` are available along
43
+ with a fresh `context.state` object for this chain. State never survives the
44
+ request. Header edits are visible downstream; editing the request does not
45
+ reroute it or change already validated inputs. Request bodies are single-use:
46
+ reading one consumes it for downstream code. There is no `clone()` or streaming
47
+ API; pass parsed data through `context.state` when needed.
48
+
49
+ Function responses support the existing text/JSON guest API. To transform their
50
+ body, read it and return a new `Response`. Native redirect/respond/link/page/static/
51
+ download bodies are opaque and cannot be read through `text()` or `json()`.
52
+ Returning the same native response preserves original bytes, including binary
53
+ files, ranges and HEAD lengths. You may add headers, but cannot change its
54
+ original status or existing native headers while preserving that body. To replace
55
+ status, destination or content, return a new `Response` instead. Replacement
56
+ responses follow the normal sandbox text/JSON and size limits. To wrap a shared
57
+ template around file content, render it through a function at build time and
58
+ publish the result: see [prerendering](PRERENDER.md).
59
+
60
+ Route selection, enabled/expiry checks, methods and input/body validation run
61
+ before middleware. Their errors do not pass through the chain. A missing file
62
+ inside a selected static mount is a downstream 404 response. Stored-link lookup
63
+ errors (missing, disabled, expired or unavailable store) precede middleware. YAML
64
+ `response.headers` apply last and override matching middleware headers. Runtime
65
+ framing and asset metadata protections still apply.
66
+
67
+ ## Isolation and testing
68
+
69
+ The whole chain and handler run in one fresh QuickJS/WASM guest with one memory
70
+ budget and one deadline. No Node, filesystem, shell, fetch or ambient environment
71
+ is exposed. Modules can only access this route's declared dependency graphs.
72
+ All middleware receive that route's approved bindings, so review the whole chain;
73
+ source changes invalidate grants. See [security](FUNCTION-SECURITY.md).
74
+
75
+ Invalid responses and repeated `next()` calls fail with 502, exhausted capacity
76
+ returns 503, and the shared deadline returns 504. Middleware cannot extend the
77
+ deadline or catch the outer worker termination. Forgotten downstream work is
78
+ still drained within that deadline.
79
+
80
+ Include explicit request fixtures for middleware-wrapped routes: test success,
81
+ early responses, validation failures and every configured method. Audit will
82
+ report missing coverage instead of assuming native handler behavior. Benchmark
83
+ with middleware enabled to measure its actual sandbox overhead.
@@ -0,0 +1,132 @@
1
+ # Monitoring a URLCode deployment
2
+
3
+ URLCode emits one JSON object per line on stdout, answers two unauthenticated
4
+ probes and, when an operator opts in, serves in-process counters in Prometheus
5
+ format. Everything below is built from those sources, and the example
6
+ configuration in [`examples/monitoring/`](../examples/monitoring/) is the
7
+ runnable form of it. The contract behind all of it (the event catalogue, the
8
+ observer interface for in-process sinks, the metrics snapshot and the
9
+ exposition endpoint) is in [observability](OBSERVABILITY.md).
10
+
11
+ What you can measure is shaped by a deliberate logging decision: records carry
12
+ **no URL, query string, header, body, binding or user exception text**. You can
13
+ alert on error rates and latency per configured route; you cannot get per-URL
14
+ analytics from these logs, and adding it would mean logging user data. See
15
+ [operations](OPERATIONS.md).
16
+
17
+ ## Probes
18
+
19
+ | Endpoint | Meaning | Alert when |
20
+ |---|---|---|
21
+ | `GET /_urlcode/health` | The process is alive and not shutting down. | It fails at all (`UrlcodeDown`). |
22
+ | `GET /_urlcode/ready` | The active snapshot, every function worker and every configured link-store reader are available. | It fails for longer than replacement takes (`UrlcodeNotReady`). |
23
+
24
+ Both return `{status, version, routes}`. A third endpoint,
25
+ `GET /_urlcode/metrics`, exists only with `startServer({ metrics: true })` and
26
+ answers 404 otherwise; see [metrics](#metrics) below. Keep both: alerting only on health hides
27
+ a process that is up and serving nothing, while alerting only on readiness pages
28
+ for a brief, self-healing worker replacement. Probes have their own bounded
29
+ admission budget (`--max-in-flight-health`), so they keep answering while the
30
+ application sheds load — but they are unauthenticated and disclose the
31
+ configuration digest and route count, so keep them internal.
32
+
33
+ ## Log records
34
+
35
+ | Event | Fields | Why it matters |
36
+ |---|---|---|
37
+ | `request` | `requestId`, `status`, `durationMs`; plus `method` and `route` with `--request-log detailed` | Error rate and latency per route. `route` is the configured pattern such as `/u/{id}`, never the requested path. |
38
+ | `reload` | `status` (`ok`/`rejected`); `version` and `routes` on `ok` | A `rejected` reload means the last-good snapshot is still serving and a deploy did not take effect. |
39
+ | `watch` | `status` | Development watcher failure; not used by `serve`. |
40
+ | `function_worker` | `status` (`started`/`restarting`), `slot`; `attempt` and `delayMs` on `restarting` | Sustained `restarting` means a function is failing on real traffic. |
41
+ | `link_store_worker` | `status`, `readOnly`, `attempt`, `delayMs` | The same signal for link-store connections. `status: "restarting"` reports an automatic replacement with its backoff; sustained restarts mean the underlying fault is not recoverable. |
42
+ | `link_observer` | `status` (`failed`/`dropped`/`closed`); `reason` on `failed`; `dropped` on `dropped`; the delivery totals on `closed` | Only when an operator enables `linkEvents`. The link event channel below could not keep up or its collector failed. `dropped` means click records were discarded; like `logs_dropped`, anything built on that channel is incomplete while it fires. |
43
+ | `logs_dropped` | `count` | The logger shed records because the collector fell behind. Every other signal is unreliable while this fires. |
44
+ | `observer` | `status` (`failed`), `name` | An in-process observer threw; the request was unaffected. Written to the log only, never to observers. Sustained failures mean the observer's own sink is broken. |
45
+ | `throttle`, `agents`, `cache` | `route`, `outcome`; `remaining` or `list` | Policy decisions; see [policies](POLICIES.md). `throttle` logs `allowed` only in report mode. |
46
+ | `site` | `key`, `path`, `status` (`generated`/`shadowed`); or `severity` (`info`/`warning`) and `message` | Activation records for [site conventions](SITE.md). `shadowed` means a declared route took the path; an `info`/`warning` line reports an omitted `Sitemap:` line (no `--origin`), skipped list names or a far-future `security.txt` expiry. |
47
+ | `management_request` | `timestamp`, `requestId`, `collection`, `action`, `authenticated`, `principal`, `status`, `outcome`, `durationMs` | Operator activity on the link-management API. `status` 0 means no response headers were sent before the peer disconnected; such a request may still have committed a mutation. |
48
+
49
+ ### The link event channel
50
+
51
+ `link_request` is **not** a stdout record. It is delivered to an `observe()`
52
+ function the embedding operator process supplies, after the response is over, so
53
+ it can never change, delay or fail a redirect. It carries `requestId`,
54
+ `collection`, `route`, `method`, `status`, `outcome`
55
+ (`completed`/`aborted`/`missing`/`disabled`/`expired`/`invalid_code`/`invalid_record`/`unavailable`)
56
+ and `durationMs`. The short code is redacted unless `includeCode` is set, because
57
+ a code identifies the link somebody followed.
58
+
59
+ The queue is bounded: under overload it drops events and reports the count
60
+ through `link_observer` rather than growing memory. Alert on those drops if you
61
+ count clicks — a quiet channel and a dropping channel look identical downstream.
62
+
63
+ Startup prints `listening` with the effective `origin`, which is what functions
64
+ and absolute URLs see. Behind a proxy or tunnel this must be your public origin;
65
+ forwarded headers are deliberately not trusted. See [tunnels](TUNNELS.md).
66
+
67
+ ## Metrics
68
+
69
+ `startServer({ metrics: true })` serves `GET /_urlcode/metrics` in Prometheus
70
+ text format: requests by status class and by configured route, in-flight
71
+ gauges, shed 503s, reloads, worker restarts and healthy slots, policy
72
+ outcomes, link outcomes, dropped logs and observer errors, all prefixed
73
+ `urlcode_`. The same numbers are available in process as `app.metrics()`. The
74
+ endpoint shares the probes' admission budget and bind host and is off by
75
+ default; it discloses route patterns and traffic shape, so keep it internal
76
+ like the probes. [`examples/monitoring/prometheus-scrape.yaml`](../examples/monitoring/prometheus-scrape.yaml)
77
+ scrapes it directly, without a log pipeline. Field names and label sets are
78
+ fixed in [observability](OBSERVABILITY.md).
79
+
80
+ If you would rather keep everything in one process, an observer passed as
81
+ `startServer({ observers })` receives every log record and a periodic metrics
82
+ snapshot; the same page shows an OpenTelemetry sketch.
83
+
84
+ ## What to alert on
85
+
86
+ The example rules in
87
+ [`examples/monitoring/prometheus-rules.yaml`](../examples/monitoring/prometheus-rules.yaml)
88
+ cover:
89
+
90
+ - **`UrlcodeDown`** — liveness probe failing. Process-level; check the supervisor.
91
+ - **`UrlcodeNotReady`** — readiness failing for more than a few minutes.
92
+ - **`UrlcodeServerErrors`** — over 5% 5xx on a route. 502 is a function error,
93
+ 503 is capacity, 504 is a deadline.
94
+ - **`UrlcodeCapacityShedding`** — sustained 503. Raise `--max-in-flight` or
95
+ `--workers`, or find what is occupying the pool.
96
+ - **`UrlcodeWorkerRestartLoop`** — replacement backs off but never stops, so a
97
+ persistent cause appears as a steady restart rate rather than a stopped pool.
98
+ This is the signal that a function is exceeding its deadline on real traffic.
99
+ - **`UrlcodeLogsDropped`** — the collector is behind, so the other rules are
100
+ blind until it recovers.
101
+
102
+ Pick service objectives for your own application; these thresholds are a
103
+ starting point, not a recommendation for your workload.
104
+
105
+ ## Wiring it up
106
+
107
+ 1. Send the process's stdout to a collector. The runtime never writes log files
108
+ and owns no rotation or retention; that belongs to the collector.
109
+ 2. Get counters either by scraping `/_urlcode/metrics` (enable `metrics`
110
+ and load [`examples/monitoring/prometheus-scrape.yaml`](../examples/monitoring/prometheus-scrape.yaml))
111
+ or by deriving them from the JSON records — see
112
+ [`examples/monitoring/vector.toml`](../examples/monitoring/vector.toml), which
113
+ produces `urlcode_requests_total`, `urlcode_worker_restarts_total` and
114
+ `urlcode_logs_dropped_total`. Fluent Bit, Promtail and Alloy work equally
115
+ well; the field names are what matter. The endpoint labels requests by
116
+ `status_class` where the log pipeline keeps the exact `status`; the example
117
+ rules carry both forms.
118
+ 3. Probe both endpoints with blackbox_exporter — see
119
+ [`examples/monitoring/blackbox-jobs.yaml`](../examples/monitoring/blackbox-jobs.yaml).
120
+ 4. Load the alert rules and set the thresholds to your objectives.
121
+
122
+ Latency percentiles need a histogram; `durationMs` is per record, so have the
123
+ log pipeline bucket it rather than averaging in the alert.
124
+
125
+ ## What this does not give you
126
+
127
+ Dashboards here describe one process. There is no built-in tracing, no
128
+ per-URL analytics, no distributed aggregation, no metrics persistence across
129
+ restarts and no automatic capacity management. The example configuration is a starting point that has not been
130
+ run against a production workload; validate it in your own environment before
131
+ relying on it, and run the drills in
132
+ [release readiness](RELEASE-READINESS.md) before treating any of it as proof.
@@ -0,0 +1,229 @@
1
+ # Observability
2
+
3
+ URLCode reports what it does through three interfaces built on one stream of
4
+ events: the JSON log on stdout, operator **observers** that receive the same
5
+ events in process, and a **metrics snapshot** of counters derived from them,
6
+ optionally served in Prometheus format. Observers mirror [host plugins](PLUGINS.md):
7
+ they are JavaScript an operator passes to `startServer` or `createRuntime`,
8
+ never something a project's YAML can name, and they run with the host's
9
+ privileges. [Monitoring](MONITORING.md) is the operator's guide to probes,
10
+ recipes and alerts; this page is the contract.
11
+
12
+ ```js
13
+ import { startServer } from '@jimhoyd/urlcode';
14
+
15
+ await startServer({
16
+ project: './site',
17
+ observers: [myObserver], // in-process event and metrics sinks
18
+ metrics: true, // GET /_urlcode/metrics, Prometheus text format
19
+ });
20
+ ```
21
+
22
+ ## Event catalogue
23
+
24
+ `events` in `@jimhoyd/urlcode/observability` is a frozen object mapping each event name
25
+ to the complete list of fields it may carry. A test runs a real server and
26
+ holds every record to it, so a field or event that is not in the table below
27
+ does not ship. Fields marked *optional* are present only in the situations
28
+ named.
29
+
30
+ | Event | Fields | Emitted when |
31
+ |---|---|---|
32
+ | `request` | `requestId` string, `status` integer, `durationMs` number; `method` string and `route` string or `null` with `--request-log detailed` | Every response the server wrote, including probes and shed 503s. `route` is the configured pattern (`/u/{id}`) or the probe path, never the requested path. |
33
+ | `reload` | `status` `ok`/`rejected`; `version` string and `routes` integer on `ok` | `app.reload()` or the development watcher swapped, or refused to swap, the snapshot. |
34
+ | `watch` | `status` `failed` | The development watcher could not fingerprint the project. |
35
+ | `function_worker` | `status` `started`/`restarting`, `slot` integer; `attempt` and `delayMs` integers on `restarting` | A function worker became ready or is scheduled for replacement. |
36
+ | `link_store_worker` | `status` `started`/`restarting`, `readOnly` boolean; `attempt` and `delayMs` on `restarting` | The same for a link-store connection. |
37
+ | `link_observer` | `status` `failed`/`dropped`/`closed`; `reason` `timeout`/`error` on `failed`; `dropped` integer on `dropped`; `queued`, `delivered`, `dropped`, `failed`, `timedOut`, `closed` on `closed` | Only with `linkEvents`: the link collector failed, the queue shed events, or the channel drained at shutdown. |
38
+ | `link_request` | `requestId`, `collection`, `route`, `code` (only with `includeCode`), `method`, `status`, `outcome`, `durationMs` | Delivered to the `linkEvents.observe` function only, after the response is over. It never reaches the log or observers; its `outcome` is counted in the metrics. |
39
+ | `logs_dropped` | `count` integer | The JSON logger shed records because stdout was not writable. Written by the logger itself, so observers do not see it. |
40
+ | `observer` | `status` `failed`, `name` string | An observer hook threw or rejected. Written to the default log only, never to observers. |
41
+ | `throttle` | `route`, `outcome` `allowed`/`exceeded`, `remaining` integer | A throttle decision. `allowed` is logged only in `mode: report`; enforce mode logs refusals. |
42
+ | `agents` | `route`, `list` string, `outcome` `denied`/`reported` | A User-Agent matched a list. The list name is logged, never the header. |
43
+ | `cache` | `route`, `outcome` `hit`/`stale`/`miss`/`store` | A cache lookup or store. |
44
+ | `management_request` | `timestamp`, `requestId`, `collection`, `action`, `authenticated`, `principal`, `status`, `outcome`, `durationMs` | The link-management API (`startLinkApi`), which has its own logger; `principal` is the operator-configured principal id. |
45
+ | `listening` | `address`, `port`, `mode`, `origin` | Printed once by the CLI at startup, not emitted by the server. |
46
+
47
+ Every event carries `event` (its name). Numbers are JSON numbers, never
48
+ strings.
49
+
50
+ ### Privacy guarantees
51
+
52
+ No event, snapshot or exposition carries a request URL, path, query string,
53
+ header, body, client address, User-Agent string, binding, secret, stored link
54
+ destination or user exception text. `route` is always a configured pattern
55
+ from reviewed YAML. `requestId` is server-generated unless
56
+ `--trust-request-id` accepts one from a trusted proxy. `code` in
57
+ `link_request` is redacted unless the operator sets `includeCode`. An observer
58
+ that logs should keep the same rule; nothing in an event lets it break it.
59
+
60
+ ## Observers
61
+
62
+ ```js
63
+ const myObserver = {
64
+ name: 'forwarder', // ^[a-z][a-z0-9-]{0,63}$, unique per server
65
+ version: '1.0.0', // any string up to 64 characters
66
+ onEvent(event) {}, // every record the JSON logger writes, in order
67
+ onMetrics(snapshot) {}, // the metrics snapshot, on the interval and at close
68
+ async onClose() {}, // release resources; reverse order
69
+ };
70
+ ```
71
+
72
+ In TypeScript the contract is `Observer` from `@jimhoyd/urlcode/observability` (also
73
+ exported from `urlcode`), with `ObserverEvent` for a record and
74
+ `MetricsSnapshot` for what `onMetrics` receives; the declarations ship with the
75
+ package:
76
+
77
+ ```ts
78
+ import type { Observer, ObserverEvent, MetricsSnapshot } from '@jimhoyd/urlcode/observability';
79
+
80
+ const myObserver: Observer = {
81
+ name: 'forwarder',
82
+ version: '1.0.0',
83
+ onEvent(event: ObserverEvent) { queue.push(event); },
84
+ onMetrics(snapshot: MetricsSnapshot) { gauge.set(snapshot.requests.inFlight); },
85
+ };
86
+ ```
87
+
88
+ Validation (`validateObservers`) matches plugins: at most 32 observers, each
89
+ an object with a kebab-case `name` no other observer uses, a `version` string,
90
+ every declared hook a function and at least one present. It runs before the
91
+ listener starts, so a bad observer fails startup rather than a request.
92
+
93
+ `onEvent` receives the same object the logger serialised, after the logger,
94
+ observers in array order. Do not mutate it. It runs on the request path, so
95
+ keep it cheap: buffer and flush on a timer rather than awaiting a network
96
+ call. A hook that throws or returns a rejecting promise is isolated: the
97
+ request is unaffected, the next observer still runs, `observers.errors` in
98
+ the snapshot increments and one `observer` record goes to the default log.
99
+ Nothing is retried; an observer that needs delivery guarantees owns its own
100
+ queue, like the [link event channel](MONITORING.md#the-link-event-channel).
101
+
102
+ `onMetrics` receives a fresh snapshot every `metricsIntervalMs`
103
+ (`startServer` option, `0` off by default, 1 s to 1 h) and once at `close()`.
104
+ `onClose` runs in reverse order after the runtime and the link channel have
105
+ drained. `app.observers` lists the `{ name, version }` pairs.
106
+
107
+ `createRuntime(project, { observers })` takes the same array for embedding
108
+ without the server: the runtime's own sink and counters are then yours, and
109
+ `runtime.metrics()` returns its snapshot. `startServer` never passes its
110
+ observers down to the runtimes it creates, so a reload does not re-register
111
+ them and counters survive reloads.
112
+
113
+ `createObserverSink(observers, fallbackLog)` is the fan-out itself, exported
114
+ for tests and custom hosts: it returns a `log(event)` function with `.metrics`,
115
+ `.publish(snapshot)` and `.close()`.
116
+
117
+ ## Metrics snapshot
118
+
119
+ `app.metrics()` and `runtime.metrics()` return a plain object, safe to
120
+ `JSON.stringify`, of counters since the process started serving. Numbers
121
+ only; the one keyed table is `requests.byRoute`, keyed by configured pattern
122
+ and capped at 10 000 keys.
123
+
124
+ | Field | Type | Meaning |
125
+ |---|---|---|
126
+ | `version` | gauge | Snapshot shape version, currently `1`. |
127
+ | `uptimeSeconds`, `rssBytes` | gauge | Process facts. |
128
+ | `requests.total`, `requests.byStatusClass.{2xx,3xx,4xx,5xx}` | counter | Application responses, including shed 503s. |
129
+ | `requests.inFlight` | gauge | Requests holding application admission now. |
130
+ | `requests.byRoute[pattern]` | counter | Responses per matched route. A shed or unmatched request has no route. |
131
+ | `health.total`, `health.byStatusClass`, `health.inFlight` | counter, gauge | The probe budget: `/_urlcode/health`, `/_urlcode/ready` and `/_urlcode/metrics`. |
132
+ | `shed.requests`, `shed.health` | counter | 503s answered because an admission budget was full. |
133
+ | `reloads.ok`, `reloads.rejected` | counter | Snapshot swaps. |
134
+ | `watch.failed` | counter | Development watcher failures. |
135
+ | `functionWorkers.started`, `functionWorkers.restarts` | counter | Worker starts and scheduled replacements. |
136
+ | `functionWorkers.healthySlots`, `functionWorkers.slots` | gauge | Ready slots and configured slots of the serving runtime. |
137
+ | `linkStoreWorkers.started`, `linkStoreWorkers.restarts` | counter | The same for link-store connections. |
138
+ | `policies.throttle.{allowed,exceeded}` | counter | Throttle decisions (see the catalogue for what enforce mode logs). |
139
+ | `policies.agents.{denied,reported}` | counter | Agents decisions. |
140
+ | `policies.cache.{hit,stale,miss,store}` | counter | Cache outcomes. |
141
+ | `linkRequests.{completed,aborted,missing,disabled,expired,invalid_code,invalid_record,unavailable}` | counter | Dynamic link outcomes, counted whether or not `linkEvents` is set. |
142
+ | `linkObserver.failed`, `linkObserver.dropped` | counter | Link collector failures and dropped events. |
143
+ | `logsDropped` | counter | Records the JSON logger shed. |
144
+ | `observers.errors` | counter | Observer hooks that threw or rejected. |
145
+
146
+ Policy counters are derived from the `throttle`, `agents` and `cache` events
147
+ as they pass through the sink, so the policies themselves have no metrics
148
+ code. Runtime facts that never become events (admission, shedding, slot
149
+ health) are recorded by the server directly. Counters are per process;
150
+ aggregation across replicas is the scraper's job.
151
+
152
+ ## Prometheus exposition
153
+
154
+ `startServer({ metrics: true })` serves `GET /_urlcode/metrics` as
155
+ `text/plain; version=0.0.4`, rendered from the same snapshot by
156
+ `renderPrometheus(snapshot)`, a pure function you can also call yourself.
157
+ Every metric is prefixed `urlcode_`; counters end in `_total`; the only labels
158
+ are `status_class`, `route` and `outcome`.
159
+
160
+ ```
161
+ # HELP urlcode_requests_total Application requests answered since start, by status class.
162
+ # TYPE urlcode_requests_total counter
163
+ urlcode_requests_total{status_class="2xx"} 1042
164
+ urlcode_route_requests_total{route="/u/{id}"} 977
165
+ urlcode_requests_in_flight 3
166
+ urlcode_shed_total{outcome="requests"} 0
167
+ urlcode_reloads_total{outcome="ok"} 2
168
+ urlcode_function_worker_restarts_total 0
169
+ urlcode_function_worker_healthy_slots 2
170
+ urlcode_throttle_total{outcome="exceeded"} 14
171
+ urlcode_cache_total{outcome="hit"} 511
172
+ urlcode_link_requests_total{outcome="completed"} 0
173
+ urlcode_logs_dropped_total 0
174
+ urlcode_observer_errors_total 0
175
+ urlcode_uptime_seconds 86400
176
+ urlcode_process_rss_bytes 71303168
177
+ ```
178
+
179
+ The endpoint is **off by default**. It shares the probes' admission budget
180
+ (`--max-in-flight-health`) and the same bind host, which is `127.0.0.1`
181
+ unless `--host` says otherwise. Like the probes it is unauthenticated and
182
+ discloses route patterns and traffic shape, so **do not expose it publicly**:
183
+ keep it on an internal interface or restrict it at the ingress. A scrape
184
+ counts under `health`, not under application requests. See
185
+ [`examples/monitoring/prometheus-scrape.yaml`](../examples/monitoring/prometheus-scrape.yaml).
186
+
187
+ ## OpenTelemetry sketch
188
+
189
+ The runtime has no OpenTelemetry dependency. An observer can forward events
190
+ to an OTLP exporter and map the snapshot onto instruments; this is a sketch,
191
+ not shipped code, and omits batching, resource attributes and error handling.
192
+
193
+ ```js
194
+ // Sketch. `logs` and `meter` come from the OpenTelemetry SDK the operator
195
+ // configures; the runtime knows nothing about them.
196
+ export function otelObserver({ logger, meter }) {
197
+ const requests = meter.createCounter('urlcode.requests', { unit: '{request}' });
198
+ const inFlight = meter.createObservableGauge('urlcode.requests.in_flight');
199
+ let last;
200
+ inFlight.addCallback(result => { if (last) result.observe(last.requests.inFlight); });
201
+ return {
202
+ name: 'otel', version: '0.1.0',
203
+ onEvent(event) {
204
+ // Every field is already safe to attach as an attribute.
205
+ logger.emit({ body: event.event, attributes: event });
206
+ if (event.event === 'request') requests.add(1, { status_class: `${Math.floor(event.status / 100)}xx`, route: event.route ?? '' });
207
+ },
208
+ onMetrics(snapshot) { last = snapshot; }, // the gauge reads the latest snapshot
209
+ };
210
+ }
211
+ ```
212
+
213
+ Counters in the snapshot are cumulative, so they map to OpenTelemetry
214
+ `Counter` instruments read through an observable callback, or to a
215
+ Prometheus receiver scraping `/_urlcode/metrics` directly. `durationMs` on
216
+ `request` is the input for a `Histogram`; the runtime does not bucket it.
217
+
218
+ ## What is not provided
219
+
220
+ - **Tracing.** There are no spans and no context propagation; `requestId` is
221
+ the only correlation key, and a trusted proxy can supply it.
222
+ - **Sampling.** Every event is delivered to every observer, or shed by the
223
+ logger under back-pressure and reported as `logs_dropped`.
224
+ - **Persistence and aggregation.** Counters live in process memory, reset on
225
+ restart, and describe one process. Retention and cross-replica sums belong
226
+ to the collector.
227
+ - **Per-URL analytics.** By design; see the privacy guarantees.
228
+ - **Adapters.** Vercel, Lambda and Cloudflare handlers emit through the
229
+ platform's own logging and do not take observers.
@@ -0,0 +1,42 @@
1
+ # Operational evidence and deployment acceptance
2
+
3
+ `node scripts/operational-drills.ts` creates and deletes its own temporary project
4
+ and store. It runs mixed real HTTP requests against native redirects, isolated
5
+ functions and live links while updating records; asserts responses; rejects a bad
6
+ reload; activates and rolls back a configuration; then closes all users of the
7
+ store, explicitly verifies a successful WAL checkpoint, and restores a copied database. It checks integrity, record/version and
8
+ latest durable audit revision. `URLCODE_SOAK_SECONDS=60` selects a longer run
9
+ (1–3600 seconds, default 5). Output is JSON with request count, batch p99 duration,
10
+ RSS and restore time. Batch latency is not per-request p99 or a capacity promise.
11
+ CI runs the short drill on all six supported Node/OS combinations.
12
+
13
+ The container job additionally uses `--disk-full-dir /state` on a disposable
14
+ 16 MiB tmpfs. It reserves recovery space, writes until a real volume exhaustion
15
+ failure, checks available disk space, releases the reserve, proves a subsequent
16
+ mutation succeeds, and checks integrity and equal committed link/audit counts.
17
+ Never point this option at production storage: it intentionally consumes up to
18
+ 84 MiB of writes in a newly created temporary child directory. The CI mount is
19
+ nonpersistent. An I/O device failure or power cut is a different failure mode.
20
+
21
+ ## Required proof on the intended deployment
22
+
23
+ Local/CI passes do not close these gates. The deployment owner must record:
24
+
25
+ | Exercise | Acceptance evidence |
26
+ |---|---|
27
+ | Soak | At least 24 hours at expected peak and burst load through actual TLS/ingress; native/function/live-link mix, slow clients, response correctness, p50/p95/p99, throughput, error/rejection rate, RSS plateau, CPU, FD and disk/WAL growth. Define numerical SLOs before starting |
28
+ | Restore | Restore a consistent backup onto a separate host/volume. Reconcile collection counts, latest committed revisions and audit journal; measure RPO/RTO against agreed targets. Test encrypted backup access and credential recovery |
29
+ | Logical export/restore | Run `links export` against a store under write load, restore it with `links import` onto a separate store, and reconcile record counts and every field. Prove the export's digest verifies, that a truncated stream is rejected, and that stale management ETags are discarded because the restore reassigns versions. An export carries no audit journal, so pair it with a file backup |
30
+ | Kill/restart | Kill server and writer with outstanding reads/writes. Reconcile uncertain mutations by version/request ID, run integrity checks, restore readiness, and prove no duplicate successful conditional writes |
31
+ | Disk/log exhaustion | Fill the actual disposable staging storage type and log destination. Verify bounded 503s, audit/mutation atomicity, alert delivery through an independent sink, reserve-space recovery and readiness after recovery |
32
+ | Rollback | Deploy candidate by exact digest beside last-good, run route assertions, switch ingress, drain, then switch back. Verify configuration/policy compatibility and audit continuity; never downgrade to an unaudited writer |
33
+ | Monitoring | Deliver test alerts for missing logs, sustained errors, pool rejection/failure, low disk, restart storms, backup age and failed readiness to a named on-call owner |
34
+
35
+ Record date, operator, source/app/policy/image digests, topology, hardware/limits,
36
+ commands, duration, synthetic dataset size, raw metrics/log locations, result,
37
+ RPO/RTO and unresolved findings. A reviewer signs the acceptance record; a blank
38
+ record is not a pass. Do not copy only the main file of a live WAL database.
39
+ The executable local drill uses a fully quiesced, explicitly checkpointed database intentionally. A last
40
+ read-only connection can leave WAL frames even after every connection closes;
41
+ closing alone is insufficient proof that the main database file is a full backup. For a
42
+ live-backup system, prove its SQLite-consistent snapshot method separately.