autotel 2.22.0 → 2.23.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 (110) hide show
  1. package/README.md +112 -6
  2. package/dist/auto.cjs +3 -3
  3. package/dist/auto.js +2 -2
  4. package/dist/{chunk-EWH2542B.js → chunk-3AMR5XLZ.js} +3 -3
  5. package/dist/{chunk-EWH2542B.js.map → chunk-3AMR5XLZ.js.map} +1 -1
  6. package/dist/chunk-3QXBFGKP.js +344 -0
  7. package/dist/chunk-3QXBFGKP.js.map +1 -0
  8. package/dist/{chunk-VQFF2WMP.cjs → chunk-3ZFDJJWZ.cjs} +37 -29
  9. package/dist/chunk-3ZFDJJWZ.cjs.map +1 -0
  10. package/dist/{chunk-CQC6RVLR.cjs → chunk-4RZ4JUBY.cjs} +5 -5
  11. package/dist/{chunk-CQC6RVLR.cjs.map → chunk-4RZ4JUBY.cjs.map} +1 -1
  12. package/dist/{chunk-PAVYKPCQ.js → chunk-5XUEHX7J.js} +3 -3
  13. package/dist/{chunk-PAVYKPCQ.js.map → chunk-5XUEHX7J.js.map} +1 -1
  14. package/dist/chunk-6S5RUKU3.cjs +347 -0
  15. package/dist/chunk-6S5RUKU3.cjs.map +1 -0
  16. package/dist/{chunk-BS757SL2.js → chunk-724XLWR3.js} +9 -4
  17. package/dist/chunk-724XLWR3.js.map +1 -0
  18. package/dist/chunk-7EQ4G4SI.cjs +146 -0
  19. package/dist/chunk-7EQ4G4SI.cjs.map +1 -0
  20. package/dist/{chunk-CQP5SQT4.cjs → chunk-AXFWWJF3.cjs} +7 -7
  21. package/dist/{chunk-CQP5SQT4.cjs.map → chunk-AXFWWJF3.cjs.map} +1 -1
  22. package/dist/{chunk-7NH625MS.cjs → chunk-BSZP4URK.cjs} +5 -5
  23. package/dist/{chunk-7NH625MS.cjs.map → chunk-BSZP4URK.cjs.map} +1 -1
  24. package/dist/{chunk-GZFH6P5U.js → chunk-GY4CRZSV.js} +14 -6
  25. package/dist/chunk-GY4CRZSV.js.map +1 -0
  26. package/dist/{chunk-QKUGUDXJ.cjs → chunk-HSEIUH7F.cjs} +10 -5
  27. package/dist/chunk-HSEIUH7F.cjs.map +1 -0
  28. package/dist/{chunk-DTW3WB7Z.js → chunk-IPKXURBW.js} +3 -3
  29. package/dist/{chunk-DTW3WB7Z.js.map → chunk-IPKXURBW.js.map} +1 -1
  30. package/dist/chunk-J7VGRIAJ.js +64 -0
  31. package/dist/chunk-J7VGRIAJ.js.map +1 -0
  32. package/dist/chunk-KFOHQK7X.js +144 -0
  33. package/dist/chunk-KFOHQK7X.js.map +1 -0
  34. package/dist/{chunk-4UYR46UP.cjs → chunk-MSUHW2I4.cjs} +13 -13
  35. package/dist/{chunk-4UYR46UP.cjs.map → chunk-MSUHW2I4.cjs.map} +1 -1
  36. package/dist/chunk-T4B5LB6E.cjs +66 -0
  37. package/dist/chunk-T4B5LB6E.cjs.map +1 -0
  38. package/dist/{chunk-QHT4MUED.js → chunk-WCIIFRGL.js} +3 -3
  39. package/dist/{chunk-QHT4MUED.js.map → chunk-WCIIFRGL.js.map} +1 -1
  40. package/dist/decorators.cjs +3 -3
  41. package/dist/decorators.js +3 -3
  42. package/dist/drain-pipeline.cjs +13 -0
  43. package/dist/drain-pipeline.cjs.map +1 -0
  44. package/dist/drain-pipeline.d.cts +37 -0
  45. package/dist/drain-pipeline.d.ts +37 -0
  46. package/dist/drain-pipeline.js +4 -0
  47. package/dist/drain-pipeline.js.map +1 -0
  48. package/dist/event.cjs +6 -6
  49. package/dist/event.js +3 -3
  50. package/dist/functional.cjs +10 -10
  51. package/dist/functional.js +3 -3
  52. package/dist/index.cjs +256 -41
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.d.cts +72 -3
  55. package/dist/index.d.ts +72 -3
  56. package/dist/index.js +210 -11
  57. package/dist/index.js.map +1 -1
  58. package/dist/{init-BMiXSJNM.d.cts → init-BC5aN8bh.d.cts} +18 -0
  59. package/dist/{init-ByRbNTRo.d.ts → init-_FG4IbhF.d.ts} +18 -0
  60. package/dist/instrumentation.cjs +9 -9
  61. package/dist/instrumentation.js +2 -2
  62. package/dist/messaging.cjs +7 -7
  63. package/dist/messaging.js +4 -4
  64. package/dist/parse-error.cjs +13 -0
  65. package/dist/parse-error.cjs.map +1 -0
  66. package/dist/parse-error.d.cts +13 -0
  67. package/dist/parse-error.d.ts +13 -0
  68. package/dist/parse-error.js +4 -0
  69. package/dist/parse-error.js.map +1 -0
  70. package/dist/processors.cjs +2 -2
  71. package/dist/processors.d.cts +40 -4
  72. package/dist/processors.d.ts +40 -4
  73. package/dist/processors.js +1 -1
  74. package/dist/semantic-helpers.cjs +8 -8
  75. package/dist/semantic-helpers.js +4 -4
  76. package/dist/webhook.cjs +4 -4
  77. package/dist/webhook.js +3 -3
  78. package/dist/workflow-distributed.cjs +5 -5
  79. package/dist/workflow-distributed.js +3 -3
  80. package/dist/workflow.cjs +8 -8
  81. package/dist/workflow.js +4 -4
  82. package/dist/yaml-config.d.cts +2 -1
  83. package/dist/yaml-config.d.ts +2 -1
  84. package/package.json +11 -1
  85. package/src/drain-pipeline.test.ts +68 -0
  86. package/src/drain-pipeline.ts +199 -0
  87. package/src/flatten-attributes.test.ts +76 -0
  88. package/src/flatten-attributes.ts +80 -0
  89. package/src/functional.test.ts +63 -0
  90. package/src/functional.ts +11 -3
  91. package/src/index.ts +33 -0
  92. package/src/init.ts +22 -0
  93. package/src/parse-error.test.ts +73 -0
  94. package/src/parse-error.ts +112 -0
  95. package/src/pretty-log-formatter.test.ts +123 -0
  96. package/src/pretty-log-formatter.ts +210 -0
  97. package/src/processors/canonical-log-line-processor.test.ts +81 -25
  98. package/src/processors/canonical-log-line-processor.ts +130 -42
  99. package/src/request-logger.test.ts +124 -0
  100. package/src/request-logger.ts +140 -0
  101. package/src/structured-error.test.ts +76 -0
  102. package/src/structured-error.ts +86 -0
  103. package/dist/chunk-2RQDNGV3.js +0 -126
  104. package/dist/chunk-2RQDNGV3.js.map +0 -1
  105. package/dist/chunk-BS757SL2.js.map +0 -1
  106. package/dist/chunk-GZFH6P5U.js.map +0 -1
  107. package/dist/chunk-ONK2Y22L.cjs +0 -128
  108. package/dist/chunk-ONK2Y22L.cjs.map +0 -1
  109. package/dist/chunk-QKUGUDXJ.cjs.map +0 -1
  110. package/dist/chunk-VQFF2WMP.cjs.map +0 -1
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createStructuredError } from './structured-error';
3
+ import { parseError } from './parse-error';
4
+
5
+ describe('parseError', () => {
6
+ it('parses structured errors with why/fix/link', () => {
7
+ const err = createStructuredError({
8
+ message: 'Payment failed',
9
+ status: 402,
10
+ why: 'Card declined',
11
+ fix: 'Use another card',
12
+ link: 'https://docs.example.com/payment-errors',
13
+ code: 'PAYMENT_DECLINED',
14
+ });
15
+
16
+ const parsed = parseError(err);
17
+ expect(parsed).toMatchObject({
18
+ message: 'Payment failed',
19
+ status: 402,
20
+ why: 'Card declined',
21
+ fix: 'Use another card',
22
+ link: 'https://docs.example.com/payment-errors',
23
+ code: 'PAYMENT_DECLINED',
24
+ raw: err,
25
+ });
26
+ });
27
+
28
+ it('parses fetch-like nested data payloads', () => {
29
+ const fetchLikeError = {
30
+ message: 'Request failed',
31
+ statusCode: 409,
32
+ data: {
33
+ statusText: 'Conflict',
34
+ data: {
35
+ why: 'Order already exists',
36
+ fix: 'Use idempotency key',
37
+ link: 'https://docs.example.com/idempotency',
38
+ code: 'ORDER_EXISTS',
39
+ },
40
+ },
41
+ };
42
+
43
+ const parsed = parseError(fetchLikeError);
44
+ expect(parsed).toMatchObject({
45
+ message: 'Conflict',
46
+ status: 409,
47
+ why: 'Order already exists',
48
+ fix: 'Use idempotency key',
49
+ link: 'https://docs.example.com/idempotency',
50
+ code: 'ORDER_EXISTS',
51
+ raw: fetchLikeError,
52
+ });
53
+ });
54
+
55
+ it('preserves details from structured errors', () => {
56
+ const err = createStructuredError({
57
+ message: 'Export failed',
58
+ status: 500,
59
+ details: { retryable: true, provider: 'stripe' },
60
+ });
61
+
62
+ const parsed = parseError(err);
63
+ expect(parsed.details).toEqual({ retryable: true, provider: 'stripe' });
64
+ });
65
+
66
+ it('falls back for unknown values', () => {
67
+ expect(parseError('boom')).toMatchObject({
68
+ message: 'boom',
69
+ status: 500,
70
+ raw: 'boom',
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,112 @@
1
+ import type { StructuredError } from './structured-error';
2
+
3
+ export interface ParsedError {
4
+ message: string;
5
+ status: number;
6
+ why?: string;
7
+ fix?: string;
8
+ link?: string;
9
+ code?: string | number;
10
+ details?: Record<string, unknown>;
11
+ raw: unknown;
12
+ }
13
+
14
+ type ErrorLike = {
15
+ message?: unknown;
16
+ status?: unknown;
17
+ statusCode?: unknown;
18
+ data?: unknown;
19
+ code?: unknown;
20
+ why?: unknown;
21
+ fix?: unknown;
22
+ link?: unknown;
23
+ details?: unknown;
24
+ };
25
+
26
+ function toStatus(value: unknown): number | undefined {
27
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
28
+ if (typeof value === 'string') {
29
+ const n = Number(value);
30
+ if (Number.isFinite(n)) return n;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function pickString(value: unknown): string | undefined {
36
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
37
+ }
38
+
39
+ function pickCode(value: unknown): string | number | undefined {
40
+ if (typeof value === 'string' || typeof value === 'number') return value;
41
+ return undefined;
42
+ }
43
+
44
+ function pickDetails(value: unknown): Record<string, unknown> | undefined {
45
+ if (value && typeof value === 'object' && value.constructor === Object) {
46
+ return value as Record<string, unknown>;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ export function parseError(error: unknown): ParsedError {
52
+ if (error instanceof Error) {
53
+ const structured = error as StructuredError;
54
+ return {
55
+ message: error.message || 'An error occurred',
56
+ status: toStatus(structured.status) ?? 500,
57
+ why: pickString(structured.why),
58
+ fix: pickString(structured.fix),
59
+ link: pickString(structured.link),
60
+ code: pickCode(structured.code),
61
+ details: pickDetails(structured.details),
62
+ raw: error,
63
+ };
64
+ }
65
+
66
+ if (error && typeof error === 'object') {
67
+ const err = error as ErrorLike;
68
+ const data =
69
+ err.data && typeof err.data === 'object'
70
+ ? (err.data as Record<string, unknown>)
71
+ : undefined;
72
+ const nested =
73
+ data?.data && typeof data.data === 'object'
74
+ ? (data.data as Record<string, unknown>)
75
+ : undefined;
76
+ const payload = nested ?? data;
77
+
78
+ const message =
79
+ pickString(data?.statusText) ||
80
+ pickString(data?.statusMessage) ||
81
+ pickString(data?.message) ||
82
+ pickString(payload?.statusText) ||
83
+ pickString(payload?.statusMessage) ||
84
+ pickString(payload?.message) ||
85
+ pickString(err.message) ||
86
+ 'An error occurred';
87
+
88
+ const status =
89
+ toStatus(payload?.status) ||
90
+ toStatus(payload?.statusCode) ||
91
+ toStatus(err.status) ||
92
+ toStatus(err.statusCode) ||
93
+ 500;
94
+
95
+ return {
96
+ message,
97
+ status,
98
+ why: pickString(payload?.why) || pickString(err.why),
99
+ fix: pickString(payload?.fix) || pickString(err.fix),
100
+ link: pickString(payload?.link) || pickString(err.link),
101
+ code: pickCode(payload?.code) || pickCode(err.code),
102
+ details: pickDetails(payload?.details) || pickDetails(err.details),
103
+ raw: error,
104
+ };
105
+ }
106
+
107
+ return {
108
+ message: String(error),
109
+ status: 500,
110
+ raw: error,
111
+ };
112
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { formatDuration, formatPrettyLogLine } from './pretty-log-formatter';
3
+ import type { CanonicalLogLineEvent } from './processors/canonical-log-line-processor';
4
+
5
+ describe('formatDuration', () => {
6
+ it('formats sub-second durations as ms', () => {
7
+ expect(formatDuration(0)).toBe('0ms');
8
+ expect(formatDuration(45)).toBe('45ms');
9
+ expect(formatDuration(999)).toBe('999ms');
10
+ });
11
+
12
+ it('formats seconds with one decimal under 10s', () => {
13
+ expect(formatDuration(1234)).toBe('1.2s');
14
+ expect(formatDuration(9500)).toBe('9.5s');
15
+ });
16
+
17
+ it('rounds seconds above 10s', () => {
18
+ expect(formatDuration(12345)).toBe('12s');
19
+ });
20
+
21
+ it('formats minutes', () => {
22
+ expect(formatDuration(65000)).toBe('1m 5s');
23
+ expect(formatDuration(120000)).toBe('2m');
24
+ });
25
+ });
26
+
27
+ describe('formatPrettyLogLine', () => {
28
+ const originalEnv = process.env.NO_COLOR;
29
+
30
+ beforeEach(() => {
31
+ process.env.NO_COLOR = '1';
32
+ });
33
+
34
+ afterEach(() => {
35
+ if (originalEnv === undefined) {
36
+ delete process.env.NO_COLOR;
37
+ } else {
38
+ process.env.NO_COLOR = originalEnv;
39
+ }
40
+ });
41
+
42
+ it('formats a basic request line', () => {
43
+ const ctx: CanonicalLogLineEvent = {
44
+ span: {} as any,
45
+ level: 'info',
46
+ message: '[checkout] Request completed',
47
+ event: {
48
+ timestamp: '2025-01-24T16:45:31.060Z',
49
+ 'service.name': 'my-app',
50
+ 'http.request.method': 'GET',
51
+ 'http.route': '/api/checkout',
52
+ 'http.response.status_code': 200,
53
+ duration_ms: 234,
54
+ status_code: 0,
55
+ operation: 'checkout',
56
+ traceId: 'abc123',
57
+ spanId: 'def456',
58
+ correlationId: 'abc1',
59
+ },
60
+ };
61
+
62
+ const output = formatPrettyLogLine(ctx);
63
+ expect(output).toContain('INFO');
64
+ expect(output).toContain('[my-app]');
65
+ expect(output).toContain('GET');
66
+ expect(output).toContain('/api/checkout');
67
+ expect(output).toContain('200');
68
+ expect(output).toContain('234ms');
69
+ });
70
+
71
+ it('includes context attributes as tree', () => {
72
+ const ctx: CanonicalLogLineEvent = {
73
+ span: {} as any,
74
+ level: 'info',
75
+ message: 'Request completed',
76
+ event: {
77
+ timestamp: '2025-01-24T16:45:31.060Z',
78
+ duration_ms: 100,
79
+ status_code: 0,
80
+ operation: 'checkout',
81
+ traceId: 'abc',
82
+ spanId: 'def',
83
+ correlationId: 'abc',
84
+ 'user.id': '123',
85
+ 'user.plan': 'premium',
86
+ 'cart.items': 3,
87
+ },
88
+ };
89
+
90
+ const output = formatPrettyLogLine(ctx);
91
+ expect(output).toContain('user');
92
+ expect(output).toContain('id=123');
93
+ expect(output).toContain('plan=premium');
94
+ expect(output).toContain('cart');
95
+ expect(output).toContain('items');
96
+ });
97
+
98
+ it('skips internal telemetry attributes', () => {
99
+ const ctx: CanonicalLogLineEvent = {
100
+ span: {} as any,
101
+ level: 'info',
102
+ message: 'Request completed',
103
+ event: {
104
+ timestamp: '2025-01-24T16:45:31.060Z',
105
+ duration_ms: 50,
106
+ status_code: 0,
107
+ operation: 'test',
108
+ traceId: 'abc',
109
+ spanId: 'def',
110
+ correlationId: 'abc',
111
+ 'telemetry.sdk.name': 'autotel',
112
+ 'otel.scope.name': 'my-scope',
113
+ 'service.name': 'my-app',
114
+ custom_field: 'visible',
115
+ },
116
+ };
117
+
118
+ const output = formatPrettyLogLine(ctx);
119
+ expect(output).not.toContain('telemetry.sdk.name');
120
+ expect(output).not.toContain('otel.scope.name');
121
+ expect(output).toContain('custom_field');
122
+ });
123
+ });
@@ -0,0 +1,210 @@
1
+ import type { CanonicalLogLineEvent } from './processors/canonical-log-line-processor';
2
+
3
+ const RESET = '\x1b[0m';
4
+ const DIM = '\x1b[2m';
5
+ const BOLD = '\x1b[1m';
6
+ const RED = '\x1b[31m';
7
+ const YELLOW = '\x1b[33m';
8
+ const GREEN = '\x1b[32m';
9
+ const CYAN = '\x1b[36m';
10
+ const GRAY = '\x1b[90m';
11
+
12
+ const LEVEL_COLORS: Record<string, string> = {
13
+ debug: GRAY,
14
+ info: GREEN,
15
+ warn: YELLOW,
16
+ error: RED,
17
+ };
18
+
19
+ /** Internal OTel attributes to skip in pretty output. */
20
+ const SKIP_PREFIXES = [
21
+ 'telemetry.',
22
+ 'otel.',
23
+ 'process.',
24
+ 'os.',
25
+ 'host.',
26
+ 'service.',
27
+ 'autotel.',
28
+ ];
29
+
30
+ const SKIP_KEYS = new Set([
31
+ 'operation',
32
+ 'traceId',
33
+ 'spanId',
34
+ 'correlationId',
35
+ 'duration_ms',
36
+ 'duration',
37
+ 'status_code',
38
+ 'status_message',
39
+ 'timestamp',
40
+ 'http.request.method',
41
+ 'url.path',
42
+ 'http.route',
43
+ 'http.response.status_code',
44
+ ]);
45
+
46
+ function useColor(): boolean {
47
+ if (typeof process !== 'undefined') {
48
+ if (process.env.NO_COLOR) return false;
49
+ if (process.env.FORCE_COLOR) return true;
50
+ if (process.stdout?.isTTY) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ function c(color: string, text: string): string {
56
+ return useColor() ? `${color}${text}${RESET}` : text;
57
+ }
58
+
59
+ /**
60
+ * Format milliseconds into a human-readable duration string.
61
+ *
62
+ * @example
63
+ * formatDuration(45) // "45ms"
64
+ * formatDuration(1234) // "1.2s"
65
+ * formatDuration(65000) // "1m 5s"
66
+ */
67
+ export function formatDuration(ms: number): string {
68
+ if (ms < 1000) return `${Math.round(ms)}ms`;
69
+ if (ms < 60_000) {
70
+ const seconds = ms / 1000;
71
+ return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
72
+ }
73
+ const minutes = Math.floor(ms / 60_000);
74
+ const seconds = Math.round((ms % 60_000) / 1000);
75
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
76
+ }
77
+
78
+ function formatTime(iso: string): string {
79
+ try {
80
+ const d = new Date(iso);
81
+ return d.toLocaleTimeString('en-GB', { hour12: false });
82
+ } catch {
83
+ return iso.slice(11, 19);
84
+ }
85
+ }
86
+
87
+ function formatValue(value: unknown): string {
88
+ if (typeof value === 'string') return value;
89
+ if (typeof value === 'number' || typeof value === 'boolean')
90
+ return String(value);
91
+ if (value == null) return '';
92
+ try {
93
+ return JSON.stringify(value);
94
+ } catch {
95
+ return String(value);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Group flat dot-notation attributes into a nested tree for pretty display.
101
+ * e.g. { 'user.id': '1', 'user.plan': 'pro' } → { user: { id: '1', plan: 'pro' } }
102
+ */
103
+ function groupAttributes(
104
+ event: Record<string, unknown>,
105
+ ): Record<string, unknown> {
106
+ const tree: Record<string, unknown> = {};
107
+
108
+ for (const [key, value] of Object.entries(event)) {
109
+ if (SKIP_KEYS.has(key)) continue;
110
+ if (SKIP_PREFIXES.some((p) => key.startsWith(p))) continue;
111
+ if (value == null || value === '') continue;
112
+
113
+ const parts = key.split('.');
114
+ if (parts.length === 1) {
115
+ tree[key] = value;
116
+ } else {
117
+ let current = tree;
118
+ for (let i = 0; i < parts.length - 1; i++) {
119
+ const part = parts[i]!;
120
+ if (!(part in current) || typeof current[part] !== 'object') {
121
+ current[part] = {};
122
+ }
123
+ current = current[part] as Record<string, unknown>;
124
+ }
125
+ current[parts[parts.length - 1]!] = value;
126
+ }
127
+ }
128
+
129
+ return tree;
130
+ }
131
+
132
+ function renderTree(
133
+ obj: Record<string, unknown>,
134
+ indent: string,
135
+ isLast: boolean[],
136
+ ): string[] {
137
+ const lines: string[] = [];
138
+ const entries = Object.entries(obj);
139
+
140
+ entries.forEach(([key, value], idx) => {
141
+ const last = idx === entries.length - 1;
142
+ const connector = last ? '\u2514\u2500' : '\u251c\u2500';
143
+ const prefix = indent + connector + ' ';
144
+
145
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
146
+ const nested = value as Record<string, unknown>;
147
+ const flatValues = Object.entries(nested).filter(
148
+ ([, v]) => typeof v !== 'object' || v === null,
149
+ );
150
+ const nestedObjs = Object.entries(nested).filter(
151
+ ([, v]) => typeof v === 'object' && v !== null && !Array.isArray(v),
152
+ );
153
+
154
+ if (nestedObjs.length === 0) {
155
+ const inline = flatValues
156
+ .map(([k, v]) => `${c(CYAN, k)}=${formatValue(v)}`)
157
+ .join(' ');
158
+ lines.push(`${prefix}${c(BOLD, key)}: ${inline}`);
159
+ } else {
160
+ lines.push(`${prefix}${c(BOLD, key)}:`);
161
+ const nextIndent = indent + (last ? ' ' : '\u2502 ');
162
+ lines.push(...renderTree(nested, nextIndent, [...isLast, last]));
163
+ }
164
+ } else {
165
+ lines.push(`${prefix}${c(CYAN, key)}: ${c(DIM, formatValue(value))}`);
166
+ }
167
+ });
168
+
169
+ return lines;
170
+ }
171
+
172
+ /**
173
+ * Format a canonical log line event as a pretty tree for development output.
174
+ */
175
+ export function formatPrettyLogLine(ctx: CanonicalLogLineEvent): string {
176
+ const { event, level, message } = ctx;
177
+
178
+ const timestamp = formatTime(String(event.timestamp ?? ''));
179
+ const service = event['service.name'] || event.service || '';
180
+ const method = event['http.request.method'] || '';
181
+ const path = event['http.route'] || event['url.path'] || '';
182
+ const status = event['http.response.status_code'] || event.status_code || '';
183
+ const durationMs = Number(event.duration_ms ?? 0);
184
+ const duration = formatDuration(durationMs);
185
+
186
+ const levelColor = LEVEL_COLORS[level] ?? '';
187
+ const levelStr = c(levelColor, level.toUpperCase().padEnd(5));
188
+
189
+ const parts = [c(DIM, timestamp), levelStr];
190
+ if (service) parts.push(c(DIM, `[${service}]`));
191
+ if (method) parts.push(c(BOLD, String(method)));
192
+ if (path) parts.push(String(path));
193
+ if (status) {
194
+ const statusNum = Number(status);
195
+ const statusColor =
196
+ statusNum >= 500 ? RED : statusNum >= 400 ? YELLOW : GREEN;
197
+ parts.push(c(statusColor, String(status)));
198
+ }
199
+ parts.push(c(DIM, `in ${duration}`));
200
+
201
+ const header = parts.join(' ');
202
+
203
+ const tree = groupAttributes(event);
204
+ if (Object.keys(tree).length === 0) {
205
+ return header;
206
+ }
207
+
208
+ const treeLines = renderTree(tree, ' ', []);
209
+ return [header, ...treeLines].join('\n');
210
+ }