Skip to content

Commit 735f6cb

Browse files
authored
feat: add structured logging with fancy and simple formatters to edge worker (#531)
# Enhanced Logging System for Edge Workers This PR introduces a structured logging system for edge workers with two formatter implementations: 1. **FancyFormatter**: Provides colorful, human-readable logs for local development with ANSI colors, icons, and clear visual hierarchy. 2. **SimpleFormatter**: Outputs machine-parseable key=value logs for production environments. The logging system includes specialized methods for common edge worker operations: - Task lifecycle events (started, completed, failed) - Worker startup and shutdown events - Polling and task count reporting The implementation automatically detects the environment and selects the appropriate formatter. It respects the NO_COLOR environment variable and provides consistent log levels across all methods. This structured approach will make logs more consistent, easier to read during development, and more useful for monitoring in production environments.
1 parent e09bc02 commit 735f6cb

File tree

11 files changed

+1025
-4
lines changed

11 files changed

+1025
-4
lines changed

pkgs/cli/project.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"sourceRoot": "pkgs/cli/src",
55
"projectType": "library",
66
"tags": [],
7+
"implicitDependencies": ["!edge-worker"],
78
"// targets": "to see all targets run: nx show project cli --web",
89
"targets": {
910
"build": {

pkgs/cli/tsconfig.lib.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
"include": ["src/**/*.ts"],
77
"references": [
88
{
9-
"path": "../dsl/tsconfig.lib.json"
9+
"path": "../core/tsconfig.lib.json"
1010
},
1111
{
12-
"path": "../core/tsconfig.lib.json"
12+
"path": "../dsl/tsconfig.lib.json"
1313
}
1414
],
1515
"exclude": [

pkgs/edge-worker/src/platform/logging.ts

Lines changed: 291 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Logger } from './types.js';
1+
import type { Logger, TaskLogContext, StartupContext } from './types.js';
22
import { isLocalSupabaseEnv } from '../shared/localDetection.js';
33

44
/**
@@ -11,6 +11,215 @@ export type LogFormat = 'fancy' | 'simple';
1111
*/
1212
export type LoggingEnv = Record<string, string | undefined>;
1313

14+
// ============================================================
15+
// ANSI Color Codes (16-color safe palette)
16+
// ============================================================
17+
18+
const ANSI = {
19+
// Colors
20+
blue: '\x1b[34m',
21+
green: '\x1b[32m',
22+
red: '\x1b[31m',
23+
yellow: '\x1b[33m',
24+
dim: '\x1b[2m',
25+
gray: '\x1b[90m',
26+
white: '\x1b[37m',
27+
28+
// Formatting
29+
reset: '\x1b[0m',
30+
bold: '\x1b[1m',
31+
};
32+
33+
/**
34+
* Apply ANSI color to text if colors are enabled
35+
*/
36+
function colorize(text: string, color: string, enabled: boolean): string {
37+
return enabled ? `${color}${text}${ANSI.reset}` : text;
38+
}
39+
40+
// ============================================================
41+
// Fancy Formatter (Local Dev) - Phase 3b
42+
// ============================================================
43+
44+
class FancyFormatter {
45+
constructor(
46+
private colorsEnabled: boolean,
47+
private getWorkerName: () => string,
48+
private isDebugLevel: () => boolean
49+
) {}
50+
51+
private workerPrefix(workerName?: string): string {
52+
const name = workerName ?? this.getWorkerName();
53+
return colorize(`${name}:`, ANSI.blue, this.colorsEnabled);
54+
}
55+
56+
private flowStepPath(flowSlug: string, stepSlug: string): string {
57+
return `${flowSlug}/${stepSlug}`;
58+
}
59+
60+
private identifiers(ctx: TaskLogContext): string {
61+
// Only show identifiers at debug level (Phase 3b)
62+
if (!this.isDebugLevel()) return '';
63+
return colorize(
64+
`run_id=${ctx.runId} msg_id=${ctx.msgId} worker_id=${ctx.workerId}`,
65+
ANSI.dim,
66+
this.colorsEnabled
67+
);
68+
}
69+
70+
taskStarted(ctx: TaskLogContext): string {
71+
const prefix = this.workerPrefix(ctx.workerName);
72+
const icon = colorize('›', ANSI.dim, this.colorsEnabled);
73+
const path = colorize(this.flowStepPath(ctx.flowSlug, ctx.stepSlug), ANSI.dim, this.colorsEnabled);
74+
const ids = this.identifiers(ctx);
75+
76+
const parts = [prefix, icon, path];
77+
if (ids) parts.push(ids);
78+
return parts.join(' ');
79+
}
80+
81+
taskCompleted(ctx: TaskLogContext, durationMs: number): string {
82+
const prefix = this.workerPrefix(ctx.workerName);
83+
const icon = colorize('✓', ANSI.green, this.colorsEnabled);
84+
const path = colorize(this.flowStepPath(ctx.flowSlug, ctx.stepSlug), ANSI.green, this.colorsEnabled);
85+
const duration = `${durationMs}ms`;
86+
87+
// Add retry info if present
88+
const retryInfo = ctx.retryAttempt && ctx.retryAttempt > 1
89+
? colorize(`retry ${ctx.retryAttempt - 1}`, ANSI.dim, this.colorsEnabled)
90+
: '';
91+
92+
const ids = this.identifiers(ctx);
93+
94+
const parts = [prefix, icon, path, duration];
95+
if (retryInfo) parts.push(retryInfo);
96+
if (ids) parts.push(ids);
97+
98+
return parts.join(' ');
99+
}
100+
101+
taskFailed(ctx: TaskLogContext, error: Error): string {
102+
const prefix = this.workerPrefix(ctx.workerName);
103+
const icon = colorize('✗', ANSI.red, this.colorsEnabled);
104+
const path = colorize(this.flowStepPath(ctx.flowSlug, ctx.stepSlug), ANSI.red, this.colorsEnabled);
105+
const errorMsg = colorize(error.message, ANSI.red, this.colorsEnabled);
106+
const ids = this.identifiers(ctx);
107+
108+
let result = `${prefix} ${icon} ${path}`;
109+
if (ids) result += ` ${ids}`;
110+
result += `\n${prefix} ${errorMsg}`;
111+
return result;
112+
}
113+
114+
polling(): string {
115+
const prefix = this.workerPrefix();
116+
return `${prefix} Polling...`;
117+
}
118+
119+
taskCount(count: number): string {
120+
const prefix = this.workerPrefix();
121+
122+
if (count === 0) {
123+
const message = colorize('No tasks', ANSI.dim, this.colorsEnabled);
124+
return `${prefix} ${message}`;
125+
}
126+
127+
return `${prefix} Starting ${colorize(count.toString(), ANSI.white, this.colorsEnabled)} tasks`;
128+
}
129+
130+
startupBanner(ctx: StartupContext): string[] {
131+
const arrow = colorize('➜', ANSI.green, this.colorsEnabled);
132+
const workerName = colorize(ctx.workerName, ANSI.bold, this.colorsEnabled);
133+
const workerId = colorize(`[${ctx.workerId}]`, ANSI.dim, this.colorsEnabled);
134+
135+
const lines: string[] = [
136+
`${arrow} ${workerName} ${workerId}`,
137+
` Queue: ${ctx.queueName}`,
138+
];
139+
140+
// Multi-flow banner with aligned list (Phase 3b)
141+
ctx.flows.forEach((flow, index) => {
142+
const statusIcon = flow.compilationStatus === 'compiled' || flow.compilationStatus === 'verified'
143+
? colorize('✓', ANSI.green, this.colorsEnabled)
144+
: colorize('!', ANSI.yellow, this.colorsEnabled);
145+
146+
const statusText = colorize(`(${flow.compilationStatus})`, ANSI.dim, this.colorsEnabled);
147+
const label = index === 0 ? ' Flows:' : ' ';
148+
lines.push(`${label} ${statusIcon} ${flow.flowSlug} ${statusText}`);
149+
});
150+
151+
return lines;
152+
}
153+
154+
shutdown(phase: 'deprecating' | 'waiting' | 'stopped'): string {
155+
const prefix = this.workerPrefix();
156+
const icon = colorize('ℹ', ANSI.blue, this.colorsEnabled);
157+
158+
if (phase === 'deprecating') {
159+
return `${prefix} ${icon} Marked for deprecation\n${prefix} -> Stopped accepting new messages`;
160+
} else if (phase === 'waiting') {
161+
return `${prefix} -> Waiting for pending tasks...`;
162+
} else {
163+
const checkmark = colorize('✓', ANSI.green, this.colorsEnabled);
164+
return `${prefix} ${checkmark} Stopped gracefully`;
165+
}
166+
}
167+
}
168+
169+
// ============================================================
170+
// Simple Formatter (Hosted) - Phase 3b
171+
// ============================================================
172+
173+
class SimpleFormatter {
174+
constructor(private getWorkerName: () => string) {}
175+
176+
taskStarted(ctx: TaskLogContext): string {
177+
// Phase 3b: worker=X queue=Y flow=Z step=W format
178+
return `[DEBUG] worker=${ctx.workerName} queue=${ctx.queueName} flow=${ctx.flowSlug} step=${ctx.stepSlug} status=started run_id=${ctx.runId} msg_id=${ctx.msgId} worker_id=${ctx.workerId}`;
179+
}
180+
181+
taskCompleted(ctx: TaskLogContext, durationMs: number): string {
182+
const retry = ctx.retryAttempt && ctx.retryAttempt > 1 ? ` retry_attempt=${ctx.retryAttempt}` : '';
183+
// Phase 3b: worker=X queue=Y flow=Z step=W format
184+
return `[VERBOSE] worker=${ctx.workerName} queue=${ctx.queueName} flow=${ctx.flowSlug} step=${ctx.stepSlug} status=completed duration_ms=${durationMs} run_id=${ctx.runId} msg_id=${ctx.msgId} worker_id=${ctx.workerId}${retry}`;
185+
}
186+
187+
taskFailed(ctx: TaskLogContext, error: Error): string {
188+
// Phase 3b: worker=X queue=Y flow=Z step=W format
189+
return `[VERBOSE] worker=${ctx.workerName} queue=${ctx.queueName} flow=${ctx.flowSlug} step=${ctx.stepSlug} status=failed error="${error.message}" run_id=${ctx.runId} msg_id=${ctx.msgId} worker_id=${ctx.workerId}`;
190+
}
191+
192+
polling(): string {
193+
return `[VERBOSE] worker=${this.getWorkerName()} status=polling`;
194+
}
195+
196+
taskCount(count: number): string {
197+
if (count === 0) {
198+
return `[VERBOSE] worker=${this.getWorkerName()} status=no_tasks`;
199+
}
200+
return `[VERBOSE] worker=${this.getWorkerName()} status=starting task_count=${count}`;
201+
}
202+
203+
startupBanner(ctx: StartupContext): string[] {
204+
// Phase 3b: Multi-flow support
205+
const lines: string[] = [];
206+
for (const flow of ctx.flows) {
207+
lines.push(`[INFO] worker=${ctx.workerName} queue=${ctx.queueName} flow=${flow.flowSlug} status=${flow.compilationStatus} worker_id=${ctx.workerId}`);
208+
}
209+
return lines;
210+
}
211+
212+
shutdown(phase: 'deprecating' | 'waiting' | 'stopped'): string {
213+
if (phase === 'deprecating') {
214+
return `[INFO] worker=${this.getWorkerName()} status=deprecating`;
215+
} else if (phase === 'waiting') {
216+
return `[INFO] worker=${this.getWorkerName()} status=waiting`;
217+
} else {
218+
return `[INFO] worker=${this.getWorkerName()} status=stopped`;
219+
}
220+
}
221+
}
222+
14223
/**
15224
* Creates a logging factory with dynamic workerId support and environment-based configuration
16225
* @param env - Optional environment variables for auto-configuration (NO_COLOR, EDGE_WORKER_LOG_FORMAT, etc.)
@@ -33,6 +242,7 @@ export function createLoggingFactory(env?: LoggingEnv) {
33242

34243
// Shared state for all loggers
35244
let sharedWorkerId = 'unknown';
245+
let sharedWorkerName = 'unknown';
36246

37247
// All created logger instances - using Map for efficient lookup
38248
const loggers: Map<string, Logger> = new Map();
@@ -41,6 +251,20 @@ export function createLoggingFactory(env?: LoggingEnv) {
41251
// Hierarchy: error < warn < info < verbose < debug
42252
const levels = { error: 0, warn: 1, info: 2, verbose: 3, debug: 4 };
43253

254+
// Helper to check if current log level is debug
255+
const isDebugLevel = () => {
256+
const levelValue = levels[logLevel as keyof typeof levels] ?? levels.info;
257+
return levelValue >= levels.debug;
258+
};
259+
260+
// Helper to get worker name
261+
const getWorkerName = () => sharedWorkerName;
262+
263+
// Create formatter instance based on format
264+
const formatter = format === 'fancy'
265+
? new FancyFormatter(colorsEnabled, getWorkerName, isDebugLevel)
266+
: new SimpleFormatter(getWorkerName);
267+
44268
/**
45269
* Creates a new logger for a specific module
46270
*/
@@ -100,6 +324,64 @@ export function createLoggingFactory(env?: LoggingEnv) {
100324
);
101325
}
102326
},
327+
328+
// Structured logging methods
329+
taskStarted: (ctx: TaskLogContext) => {
330+
const levelValue =
331+
levels[logLevel as keyof typeof levels] ?? levels.info;
332+
if (levelValue >= levels.debug) {
333+
console.debug(formatter.taskStarted(ctx));
334+
}
335+
},
336+
337+
taskCompleted: (ctx: TaskLogContext, durationMs: number) => {
338+
const levelValue =
339+
levels[logLevel as keyof typeof levels] ?? levels.info;
340+
if (levelValue >= levels.verbose) {
341+
console.log(formatter.taskCompleted(ctx, durationMs));
342+
}
343+
},
344+
345+
taskFailed: (ctx: TaskLogContext, error: Error) => {
346+
const levelValue =
347+
levels[logLevel as keyof typeof levels] ?? levels.info;
348+
if (levelValue >= levels.verbose) {
349+
console.log(formatter.taskFailed(ctx, error));
350+
}
351+
},
352+
353+
polling: () => {
354+
const levelValue =
355+
levels[logLevel as keyof typeof levels] ?? levels.info;
356+
if (levelValue >= levels.verbose) {
357+
console.log(formatter.polling());
358+
}
359+
},
360+
361+
taskCount: (count: number) => {
362+
const levelValue =
363+
levels[logLevel as keyof typeof levels] ?? levels.info;
364+
if (levelValue >= levels.verbose) {
365+
console.log(formatter.taskCount(count));
366+
}
367+
},
368+
369+
startupBanner: (ctx: StartupContext) => {
370+
const levelValue =
371+
levels[logLevel as keyof typeof levels] ?? levels.info;
372+
if (levelValue >= levels.info) {
373+
const lines = formatter.startupBanner(ctx);
374+
lines.forEach(line => console.info(line));
375+
}
376+
},
377+
378+
shutdown: (phase: 'deprecating' | 'waiting' | 'stopped') => {
379+
const levelValue =
380+
levels[logLevel as keyof typeof levels] ?? levels.info;
381+
if (levelValue >= levels.info) {
382+
console.info(formatter.shutdown(phase));
383+
}
384+
},
103385
};
104386

105387
// Store the logger in our registry using module as key
@@ -116,6 +398,13 @@ export function createLoggingFactory(env?: LoggingEnv) {
116398
sharedWorkerId = workerId;
117399
};
118400

401+
/**
402+
* Updates the worker name for all loggers (Phase 3b)
403+
*/
404+
const setWorkerName = (workerName: string): void => {
405+
sharedWorkerName = workerName;
406+
};
407+
119408
/**
120409
* Updates the log level for all loggers
121410
*/
@@ -126,6 +415,7 @@ export function createLoggingFactory(env?: LoggingEnv) {
126415
return {
127416
createLogger,
128417
setWorkerId,
418+
setWorkerName,
129419
setLogLevel,
130420
// Expose configuration for inspection/testing
131421
get colorsEnabled() {

0 commit comments

Comments
 (0)