@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.
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- 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
|
+
}
|