@timber-js/app 0.1.1 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +2 -1
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Dev logger — structured console output for every request in dev mode.
3
+ *
4
+ * Formats OTEL span trees into indented tree output for stderr. Spans are
5
+ * the single source of truth — no separate event system needed.
6
+ *
7
+ * Supports five modes:
8
+ * - tree (default) — indented tree per request
9
+ * - verbose — detailed tree showing every component render
10
+ * - summary — one line per request
11
+ * - json — chronological NDJSON dump of all spans
12
+ * - quiet — no output
13
+ *
14
+ * Design doc: 21-dev-server.md §"Dev Logging", 17-logging.md §"Dev Logging"
15
+ */
16
+
17
+ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
18
+
19
+ // ─── Configuration ──────────────────────────────────────────────────────────
20
+
21
+ export type DevLogMode = 'tree' | 'verbose' | 'summary' | 'json' | 'quiet';
22
+
23
+ export interface DevLoggerConfig {
24
+ /** Logging mode. Default: 'tree'. */
25
+ mode?: DevLogMode;
26
+ /** Threshold in ms to highlight slow phases. Default: 200. */
27
+ slowPhaseMs?: number;
28
+ }
29
+
30
+ // ─── ANSI Codes ─────────────────────────────────────────────────────────────
31
+
32
+ const DIM = '\x1b[2m';
33
+ const BOLD = '\x1b[1m';
34
+ const RESET = '\x1b[0m';
35
+ const GREEN = '\x1b[32m';
36
+ const YELLOW = '\x1b[33m';
37
+ const RED = '\x1b[31m';
38
+ const CYAN = '\x1b[36m';
39
+ const MAGENTA = '\x1b[35m';
40
+
41
+ /**
42
+ * Color an HTTP method for dev log output.
43
+ * GET is dimmed (it's the default/boring case), others get distinct colors.
44
+ */
45
+ function colorMethod(method: string): string {
46
+ switch (method) {
47
+ case 'GET':
48
+ return `${DIM}${method}${RESET}`;
49
+ case 'POST':
50
+ return `${GREEN}${BOLD}${method}${RESET}`;
51
+ case 'PUT':
52
+ return `${YELLOW}${BOLD}${method}${RESET}`;
53
+ case 'DELETE':
54
+ return `${RED}${BOLD}${method}${RESET}`;
55
+ case 'PATCH':
56
+ return `${CYAN}${BOLD}${method}${RESET}`;
57
+ case 'HEAD':
58
+ return `${DIM}${method}${RESET}`;
59
+ case 'OPTIONS':
60
+ return `${MAGENTA}${BOLD}${method}${RESET}`;
61
+ default:
62
+ return `${BOLD}${method}${RESET}`;
63
+ }
64
+ }
65
+
66
+ // ─── HrTime Helpers ─────────────────────────────────────────────────────────
67
+
68
+ type HrTime = [number, number];
69
+
70
+ function hrTimeToMs(hr: HrTime): number {
71
+ return hr[0] * 1000 + hr[1] / 1_000_000;
72
+ }
73
+
74
+ function relativeMs(time: HrTime, rootStart: HrTime): number {
75
+ return hrTimeToMs(time) - hrTimeToMs(rootStart);
76
+ }
77
+
78
+ // ─── Span → Tree Mapping ────────────────────────────────────────────────────
79
+
80
+ /** Map span names to display labels and environment tags. */
81
+ function spanLabel(span: ReadableSpan): { label: string; env: string } {
82
+ const attrs = span.attributes;
83
+ switch (span.name) {
84
+ case 'timber.proxy':
85
+ return { label: 'proxy.ts', env: 'proxy' };
86
+ case 'timber.middleware':
87
+ return { label: 'middleware.ts', env: 'rsc' };
88
+ case 'timber.render':
89
+ return { label: 'render', env: 'rsc' };
90
+ case 'timber.access': {
91
+ const seg = attrs['timber.segment'] ?? 'segment';
92
+ return { label: `AccessGate (${seg})`, env: 'rsc' };
93
+ }
94
+ case 'timber.ssr':
95
+ return { label: 'hydration render', env: 'ssr' };
96
+ case 'timber.action': {
97
+ const name = attrs['timber.action_name'] ?? 'action';
98
+ return { label: String(name), env: 'rsc' };
99
+ }
100
+ case 'timber.metadata':
101
+ return { label: 'metadata()', env: 'rsc' };
102
+ case 'timber.layout': {
103
+ const seg = attrs['timber.segment'] ?? '/';
104
+ return { label: `layout ${seg}`, env: 'rsc' };
105
+ }
106
+ case 'timber.page': {
107
+ const route = attrs['timber.route'] ?? '/';
108
+ return { label: `page ${route}`, env: 'rsc' };
109
+ }
110
+ default:
111
+ return { label: span.name, env: 'rsc' };
112
+ }
113
+ }
114
+
115
+ // ─── Tree Node ──────────────────────────────────────────────────────────────
116
+
117
+ interface SpanTreeNode {
118
+ span: ReadableSpan;
119
+ children: SpanTreeNode[];
120
+ }
121
+
122
+ /**
123
+ * Build a tree from a flat list of spans using parentSpanId relationships.
124
+ */
125
+ function buildSpanTree(spans: ReadableSpan[]): {
126
+ root: ReadableSpan | null;
127
+ children: SpanTreeNode[];
128
+ } {
129
+ const root = spans.find((s) => s.name === 'http.server.request') ?? null;
130
+ if (!root) return { root: null, children: [] };
131
+
132
+ // Index spans by spanId for parent lookup
133
+ const bySpanId = new Map<string, SpanTreeNode>();
134
+ for (const span of spans) {
135
+ if (span === root) continue;
136
+ bySpanId.set(span.spanContext().spanId, { span, children: [] });
137
+ }
138
+
139
+ // Build parent-child relationships
140
+ const rootChildren: SpanTreeNode[] = [];
141
+ for (const node of bySpanId.values()) {
142
+ const parentId = node.span.parentSpanContext?.spanId;
143
+ if (parentId === root.spanContext().spanId) {
144
+ rootChildren.push(node);
145
+ } else if (parentId && bySpanId.has(parentId)) {
146
+ bySpanId.get(parentId)!.children.push(node);
147
+ } else {
148
+ // Orphan — attach to root
149
+ rootChildren.push(node);
150
+ }
151
+ }
152
+
153
+ // Sort children by start time
154
+ const sortByStart = (a: SpanTreeNode, b: SpanTreeNode) =>
155
+ hrTimeToMs(a.span.startTime) - hrTimeToMs(b.span.startTime);
156
+
157
+ rootChildren.sort(sortByStart);
158
+ for (const node of bySpanId.values()) {
159
+ node.children.sort(sortByStart);
160
+ }
161
+
162
+ // Post-process: re-parent layout/page spans into a nested chain.
163
+ // React's concurrent rendering breaks OTEL parent chains — all layout/page
164
+ // spans end up as direct children of timber.render. Re-nest them based on
165
+ // start-time order to reflect the segment hierarchy.
166
+ for (const node of bySpanId.values()) {
167
+ if (node.span.name !== 'timber.render') continue;
168
+ reNestLayoutPageSpans(node);
169
+ }
170
+
171
+ return { root, children: rootChildren };
172
+ }
173
+
174
+ /**
175
+ * Re-parent layout and page spans under a render node to form a nested chain.
176
+ *
177
+ * Layout/page spans all appear as flat children of timber.render because OTEL
178
+ * context doesn't propagate through React's concurrent rendering. We
179
+ * reconstruct the hierarchy: each layout becomes the parent of the next
180
+ * layout/page span, forming a chain that matches the segment tree.
181
+ *
182
+ * Non-layout/page children (e.g. access gates, metadata) stay at their
183
+ * current depth.
184
+ */
185
+ function reNestLayoutPageSpans(renderNode: SpanTreeNode): void {
186
+ const layoutPageNames = new Set(['timber.layout', 'timber.page']);
187
+ const layoutPageChildren: SpanTreeNode[] = [];
188
+ const otherChildren: SpanTreeNode[] = [];
189
+
190
+ for (const child of renderNode.children) {
191
+ if (layoutPageNames.has(child.span.name)) {
192
+ layoutPageChildren.push(child);
193
+ } else {
194
+ otherChildren.push(child);
195
+ }
196
+ }
197
+
198
+ // Nothing to re-nest if 0 or 1 layout/page spans
199
+ if (layoutPageChildren.length <= 1) return;
200
+
201
+ // Chain them: first layout/page is direct child of render, second is
202
+ // child of first, etc. They're already sorted by start time.
203
+ for (let i = layoutPageChildren.length - 1; i > 0; i--) {
204
+ layoutPageChildren[i - 1]!.children.push(layoutPageChildren[i]!);
205
+ }
206
+
207
+ // Rebuild render's children: other children + only the first layout/page
208
+ renderNode.children = [...otherChildren, layoutPageChildren[0]!];
209
+
210
+ // Re-sort by start time
211
+ const sortByStart = (a: SpanTreeNode, b: SpanTreeNode) =>
212
+ hrTimeToMs(a.span.startTime) - hrTimeToMs(b.span.startTime);
213
+ renderNode.children.sort(sortByStart);
214
+ }
215
+
216
+ // ─── Log Mode Resolution ────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Resolve the effective log mode from environment variables and config.
220
+ * Environment variables override config file values per 21-dev-server.md.
221
+ */
222
+ export function resolveLogMode(config?: DevLoggerConfig): DevLogMode {
223
+ if (process.env.TIMBER_DEV_QUIET === '1') return 'quiet';
224
+ const envMode = process.env.TIMBER_DEV_LOG;
225
+ if (envMode === 'summary' || envMode === 'tree' || envMode === 'verbose' || envMode === 'json')
226
+ return envMode;
227
+ return config?.mode ?? 'tree';
228
+ }
229
+
230
+ // ─── Formatters ─────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Format spans as a full indented tree string for stderr.
234
+ */
235
+ export function formatSpanTree(spans: ReadableSpan[], config?: DevLoggerConfig): string {
236
+ const slowPhaseMs = config?.slowPhaseMs ?? 200;
237
+ const { root, children } = buildSpanTree(spans);
238
+ if (!root) return '';
239
+
240
+ const rootStart = root.startTime;
241
+ const lines: string[] = [];
242
+
243
+ // Request header line
244
+ const method = String(root.attributes['http.request.method'] ?? 'GET');
245
+ const path = String(root.attributes['url.path'] ?? '/');
246
+ const traceId = root.spanContext().traceId;
247
+ const actionName = root.attributes['timber.action_name'] as string | undefined;
248
+
249
+ const dimTrace = `${DIM}trace_id: ${traceId}${RESET}`;
250
+ if (actionName) {
251
+ const actionFile = root.attributes['timber.action_file'] as string | undefined;
252
+ lines.push(
253
+ `${BOLD}ACTION ${actionName}${actionFile ? ` (${actionFile})` : ''}${RESET} ${dimTrace}`
254
+ );
255
+ } else {
256
+ lines.push(`${colorMethod(method)} ${BOLD}${path}${RESET} ${dimTrace}`);
257
+ }
258
+
259
+ // Render child span nodes
260
+ for (let i = 0; i < children.length; i++) {
261
+ const isLast = i === children.length - 1;
262
+ formatSpanNode(children[i]!, lines, '', isLast, slowPhaseMs, rootStart);
263
+ }
264
+
265
+ // Result line
266
+ const statusCode = root.attributes['http.response.status_code'] as number | undefined;
267
+ const status = statusCode ?? (root.status.code === 2 ? 500 : 200);
268
+ const totalMs = Math.round(hrTimeToMs(root.duration));
269
+ const statusColor = status < 400 ? GREEN : status < 500 ? YELLOW : RED;
270
+ const statusText = `${status} ${httpStatusText(status)}`;
271
+
272
+ // Surface deny() signal info for 500s caused by deny-inside-suspense
273
+ const denyInfo = root.attributes['timber.deny_status'] as number | undefined;
274
+ const denyNote = denyInfo
275
+ ? `${DIM} (caused by deny(${denyInfo}) inside Suspense — status already committed)${RESET}`
276
+ : '';
277
+
278
+ lines.push(
279
+ `${statusColor}└─ ✓ ${statusText}${RESET}${DIM} total ${totalMs}ms${RESET}${denyNote}`
280
+ );
281
+
282
+ return lines.join('\n') + '\n';
283
+ }
284
+
285
+ /**
286
+ * Format a single span tree node with children, timing, and annotations.
287
+ */
288
+ function formatSpanNode(
289
+ node: SpanTreeNode,
290
+ lines: string[],
291
+ prefix: string,
292
+ isLast: boolean,
293
+ slowPhaseMs: number,
294
+ rootStart: HrTime
295
+ ): void {
296
+ const connector = isLast ? '└─' : '├─';
297
+ const childPrefix = prefix + (isLast ? ' ' : '│ ');
298
+ const { label, env } = spanLabel(node.span);
299
+ const startMs = Math.round(relativeMs(node.span.startTime, rootStart));
300
+ const endMs = Math.round(relativeMs(node.span.endTime, rootStart));
301
+ const durationMs = endMs - startMs;
302
+ const isSlow = durationMs > slowPhaseMs;
303
+
304
+ // Access results from span attributes
305
+ const accessResult = node.span.attributes['timber.result'] as string | undefined;
306
+
307
+ let timing = `${startMs}ms → ${endMs}ms`;
308
+ if (accessResult) {
309
+ const accessStatus = node.span.attributes['timber.deny_status'] as number | undefined;
310
+ const denyFile = node.span.attributes['timber.deny_file'] as string | undefined;
311
+ timing += ` → ${accessResult.toUpperCase()}${accessStatus ? ` ${accessStatus}` : ''}`;
312
+ if (denyFile) {
313
+ timing += ` (${denyFile})`;
314
+ }
315
+ }
316
+
317
+ const slowHighlight = isSlow ? YELLOW : '';
318
+ const slowReset = isSlow ? RESET : '';
319
+ const envTag = `${CYAN}[${env}]${RESET}`;
320
+ const line = `${prefix}${connector} ${envTag} ${slowHighlight}${label}${slowReset} ${DIM}${timing}${RESET}`;
321
+ lines.push(line);
322
+
323
+ // Span events (cache hits/misses) as child annotations
324
+ for (const event of node.span.events) {
325
+ if (event.name === 'timber.cache.hit' || event.name === 'timber.cache.miss') {
326
+ const key = String(event.attributes?.['key'] ?? '');
327
+ const hitMiss = event.name === 'timber.cache.hit' ? 'HIT' : 'MISS';
328
+ const durMs = event.attributes?.['duration_ms'] as number | undefined;
329
+ const durStr = durMs !== undefined ? ` ${durMs < 1 ? '<1' : Math.round(durMs)}ms` : '';
330
+ const stale = event.attributes?.['stale'] ? ' (stale)' : '';
331
+ lines.push(
332
+ `${childPrefix}${DIM}└── ${key} timber.cache ${hitMiss}${durStr}${stale}${RESET}`
333
+ );
334
+ }
335
+ }
336
+
337
+ // Render children
338
+ for (let i = 0; i < node.children.length; i++) {
339
+ const childIsLast = i === node.children.length - 1;
340
+ formatSpanNode(node.children[i]!, lines, childPrefix, childIsLast, slowPhaseMs, rootStart);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Format spans as a one-line summary.
346
+ */
347
+ export function formatSpanSummary(spans: ReadableSpan[], _config?: DevLoggerConfig): string {
348
+ const root = spans.find((s) => s.name === 'http.server.request');
349
+ if (!root) return '';
350
+
351
+ const method = String(root.attributes['http.request.method'] ?? 'GET');
352
+ const path = String(root.attributes['url.path'] ?? '/');
353
+ const statusCode = root.attributes['http.response.status_code'] as number | undefined;
354
+ const status = statusCode ?? (root.status.code === 2 ? 500 : 200);
355
+ const totalMs = Math.round(hrTimeToMs(root.duration));
356
+ const traceId = root.spanContext().traceId;
357
+ const traceIdShort = traceId.slice(0, 8);
358
+
359
+ const statusColor = status < 400 ? GREEN : status < 500 ? YELLOW : RED;
360
+ return `${colorMethod(method)} ${path} → ${statusColor}${status} ${httpStatusText(status)}${RESET} ${totalMs}ms ${DIM}trace_id: ${traceIdShort}...${RESET}\n`;
361
+ }
362
+
363
+ /**
364
+ * Format spans as chronological NDJSON for json mode.
365
+ *
366
+ * Each span is one JSON line, ordered by start time. Useful for piping
367
+ * to jq or feeding into external trace analysis tools.
368
+ */
369
+ export function formatJson(spans: ReadableSpan[]): string {
370
+ const root = spans.find((s) => s.name === 'http.server.request');
371
+ const rootStart = root?.startTime ?? ([0, 0] as HrTime);
372
+
373
+ // Sort by start time
374
+ const sorted = [...spans].sort((a, b) => hrTimeToMs(a.startTime) - hrTimeToMs(b.startTime));
375
+
376
+ const lines: string[] = [];
377
+ for (const span of sorted) {
378
+ const entry = {
379
+ name: span.name,
380
+ traceId: span.spanContext().traceId,
381
+ spanId: span.spanContext().spanId,
382
+ parentSpanId: span.parentSpanContext?.spanId,
383
+ startMs: Math.round(relativeMs(span.startTime, rootStart)),
384
+ endMs: Math.round(relativeMs(span.endTime, rootStart)),
385
+ durationMs: Math.round(hrTimeToMs(span.duration)),
386
+ attributes: span.attributes,
387
+ events: span.events.map((e) => ({
388
+ name: e.name,
389
+ timeMs: Math.round(relativeMs(e.time as HrTime, rootStart)),
390
+ attributes: e.attributes,
391
+ })),
392
+ status: span.status,
393
+ };
394
+ lines.push(JSON.stringify(entry));
395
+ }
396
+
397
+ return lines.join('\n') + '\n';
398
+ }
399
+
400
+ // ─── Helpers ────────────────────────────────────────────────────────────────
401
+
402
+ function httpStatusText(status: number): string {
403
+ const texts: Record<number, string> = {
404
+ 200: 'OK',
405
+ 201: 'Created',
406
+ 204: 'No Content',
407
+ 301: 'Moved Permanently',
408
+ 302: 'Found',
409
+ 304: 'Not Modified',
410
+ 400: 'Bad Request',
411
+ 401: 'Unauthorized',
412
+ 403: 'Forbidden',
413
+ 404: 'Not Found',
414
+ 405: 'Method Not Allowed',
415
+ 413: 'Payload Too Large',
416
+ 500: 'Internal Server Error',
417
+ };
418
+ return texts[status] ?? String(status);
419
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * DevSpanProcessor — Custom OTEL SpanProcessor that drives dev log output.
3
+ *
4
+ * Collects completed spans per-request (correlated by trace ID). When the
5
+ * root span (http.server.request) ends, all child spans are already collected
6
+ * (child spans end before parent in OTEL). The processor formats the span
7
+ * tree and writes it to stderr.
8
+ *
9
+ * This replaces the old DevLogEmitter/DevLogEvents system. OTEL spans are
10
+ * now the single source of truth for dev logging — no more parallel event
11
+ * systems that can drift.
12
+ *
13
+ * Design doc: 17-logging.md §"Dev Logging", 21-dev-server.md §"Dev Logging"
14
+ */
15
+
16
+ import type { SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base';
17
+ import type { Span, Context } from '@opentelemetry/api';
18
+ import {
19
+ formatSpanTree,
20
+ formatSpanSummary,
21
+ formatJson,
22
+ type DevLogMode,
23
+ type DevLoggerConfig,
24
+ } from './dev-logger.js';
25
+
26
+ export class DevSpanProcessor implements SpanProcessor {
27
+ private spansByTrace = new Map<string, ReadableSpan[]>();
28
+ private mode: DevLogMode;
29
+ private config: DevLoggerConfig;
30
+
31
+ constructor(config: DevLoggerConfig) {
32
+ this.config = config;
33
+ this.mode = config.mode ?? 'tree';
34
+ }
35
+
36
+ onStart(_span: Span, _context: Context): void {
37
+ // No action needed on span start — we collect on end.
38
+ }
39
+
40
+ onEnd(span: ReadableSpan): void {
41
+ const traceId = span.spanContext().traceId;
42
+
43
+ let spans = this.spansByTrace.get(traceId);
44
+ if (!spans) {
45
+ spans = [];
46
+ this.spansByTrace.set(traceId, spans);
47
+ }
48
+ spans.push(span);
49
+
50
+ // Root span signals request completion — all child spans are already
51
+ // collected because OTEL ends child spans before parent spans.
52
+ if (span.name === 'http.server.request') {
53
+ const output = this.format(spans);
54
+ if (output) {
55
+ process.stderr.write(output);
56
+ }
57
+ this.spansByTrace.delete(traceId);
58
+ }
59
+ }
60
+
61
+ private format(spans: ReadableSpan[]): string {
62
+ if (this.mode === 'quiet') return '';
63
+ if (this.mode === 'json') return formatJson(spans);
64
+ if (this.mode === 'summary') return formatSpanSummary(spans, this.config);
65
+ // Both 'tree' and 'verbose' use the tree formatter.
66
+ // verbose will show additional detail (every component render) once
67
+ // component-level spans are wired.
68
+ return formatSpanTree(spans, this.config);
69
+ }
70
+
71
+ async shutdown(): Promise<void> {
72
+ this.spansByTrace.clear();
73
+ }
74
+
75
+ async forceFlush(): Promise<void> {
76
+ // Nothing to flush — output happens synchronously in onEnd.
77
+ }
78
+ }