Skip to content

Commit 57deff5

Browse files
authored
feat(scripts): allow remote logging during dev (#928)
1 parent bb0c12d commit 57deff5

File tree

5 files changed

+154
-0
lines changed

5 files changed

+154
-0
lines changed

esbuild/dev.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export const getDevOptions = ({
3030
CONFIG_OPEN_PAYMENTS_REDIRECT_URL: JSON.stringify(
3131
'https://webmonetization.org/welcome',
3232
),
33+
CONFIG_LOG_SERVER_ENDPOINT: process.env.LOG_SERVER
34+
? JSON.stringify(process.env.LOG_SERVER)
35+
: JSON.stringify(false),
3336
},
3437
};
3538
};

esbuild/prod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const getProdOptions = ({
3737
CONFIG_OPEN_PAYMENTS_REDIRECT_URL: JSON.stringify(
3838
'https://webmonetization.org/welcome',
3939
),
40+
CONFIG_LOG_SERVER_ENDPOINT: JSON.stringify(false),
4041
},
4142
};
4243
};

scripts/log-server.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
createServer,
3+
type IncomingMessage,
4+
type ServerResponse,
5+
} from 'node:http';
6+
import { format, formatWithOptions, styleText } from 'node:util';
7+
import type { RemoteLoggerMessage } from '@/shared/logger';
8+
9+
const PORT = process.env.LOG_SERVER_PORT || 8000;
10+
const CORS_HEADERS = {
11+
'Access-Control-Allow-Private-Network': 'true',
12+
'Access-Control-Allow-Origin': '*',
13+
'Access-Control-Allow-Headers': '*',
14+
};
15+
16+
const server = createServer(async (req, res) => {
17+
if (req.method === 'OPTIONS') {
18+
return respond(res);
19+
}
20+
21+
const body = await getBody(req);
22+
if (!body) return respond(res, 400);
23+
24+
const logs = JSON.parse(body) as RemoteLoggerMessage[];
25+
for (const log of logs) {
26+
console.log(
27+
`▶ ${styleText('dim', timestampFormat.format(new Date(log.timestamp)))}`,
28+
styleText(
29+
'inverse',
30+
`[${log.logger.toUpperCase()}:${styleText(['italic', 'dim'], log.methodName)}]`,
31+
),
32+
log.msg
33+
.map((e) =>
34+
typeof e === 'string'
35+
? format('%s', e)
36+
: formatWithOptions({ colors: true }, '%o', e),
37+
)
38+
.join(' '),
39+
styleText(['italic', 'dim'], log.stackTrace[0]?.trim()),
40+
'\n',
41+
);
42+
}
43+
44+
return respond(res);
45+
});
46+
47+
server.listen(PORT, () => {
48+
console.warn(`Log server listening on port ${PORT}`);
49+
});
50+
51+
const timestampFormat = new Intl.DateTimeFormat(undefined, {
52+
hour: 'numeric',
53+
minute: 'numeric',
54+
second: 'numeric',
55+
fractionalSecondDigits: 3,
56+
hour12: false,
57+
});
58+
59+
function respond(res: ServerResponse<IncomingMessage>, statusCode = 200) {
60+
res.writeHead(statusCode, { ...CORS_HEADERS }).end();
61+
}
62+
63+
function getBody(req: IncomingMessage) {
64+
return new Promise<string>((resolve) => {
65+
let body = '';
66+
req.on('data', (chunk) => {
67+
body += chunk;
68+
});
69+
req.on('end', () => {
70+
resolve(body);
71+
});
72+
});
73+
}

src/shared/defines.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { LogLevelDesc } from 'loglevel';
22

33
declare const CONFIG_LOG_LEVEL: LogLevelDesc;
4+
declare const CONFIG_LOG_SERVER_ENDPOINT: string | false;
45
declare const CONFIG_PERMISSION_HOSTS: { origins: string[] };
56
declare const CONFIG_ALLOWED_PROTOCOLS: string[];
67
declare const CONFIG_OPEN_PAYMENTS_REDIRECT_URL: string;
78

89
export const LOG_LEVEL = CONFIG_LOG_LEVEL;
10+
export const LOG_SERVER_ENDPOINT = CONFIG_LOG_SERVER_ENDPOINT;
911
export const PERMISSION_HOSTS = CONFIG_PERMISSION_HOSTS;
1012
export const ALLOWED_PROTOCOLS = CONFIG_ALLOWED_PROTOCOLS;
1113
export const OPEN_PAYMENTS_REDIRECT_URL = CONFIG_OPEN_PAYMENTS_REDIRECT_URL;

src/shared/logger.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import log from 'loglevel';
2+
import { ThrottleBatch } from './helpers';
3+
import { LOG_SERVER_ENDPOINT } from './defines';
24

35
// TODO: Disable debug logging in production
46
export const createLogger = (level: log.LogLevelDesc = 'DEBUG') => {
@@ -16,7 +18,80 @@ export const createLogger = (level: log.LogLevelDesc = 'DEBUG') => {
1618
};
1719
log.rebuild();
1820

21+
// Provide a LOG_SERVER=http://127.0.0.1:8000 env var to enable remote
22+
// logging. The remote logging server is run via `tsx scripts/log-server.ts`
23+
//
24+
// Remote logging is excluded from bundle via treeshaking if the `LOG_SERVER`
25+
// env var is not provided.
26+
//
27+
// DO NOT ENABLE THIS FOR PRODUCTION.
28+
if (LOG_SERVER_ENDPOINT) {
29+
remoteLogger(log, LOG_SERVER_ENDPOINT);
30+
}
31+
1932
return log;
2033
};
2134

2235
export type Logger = log.RootLogger;
36+
37+
export type RemoteLoggerMessage = {
38+
msg: unknown[];
39+
timestamp: string;
40+
methodName: string;
41+
logger: string;
42+
stackTrace: string[];
43+
};
44+
45+
function remoteLogger(log: log.RootLogger, endpoint: string) {
46+
const queue = new ThrottleBatch(send, (logItems) => logItems.flat(), 500);
47+
48+
const originalFactory = log.methodFactory;
49+
log.methodFactory = (methodName, logLevel, loggerName) => {
50+
const raw = originalFactory(methodName, logLevel, loggerName);
51+
const original = raw.bind(log);
52+
// Note: this interception breaks the stack trace linenumber in devtools console
53+
return (...msgArgs: unknown[]) => {
54+
const logItem: RemoteLoggerMessage = {
55+
msg: msgArgs,
56+
methodName,
57+
timestamp: new Date().toISOString(),
58+
logger: loggerName?.toString() || '',
59+
stackTrace: formatStackTrace(getStacktrace()),
60+
};
61+
queue.enqueue(logItem);
62+
original.apply(log, msgArgs);
63+
};
64+
};
65+
log.rebuild();
66+
67+
function send(...logItems: unknown[]) {
68+
void fetch(endpoint, {
69+
method: 'POST',
70+
headers: { 'Content-Type': 'application/json' },
71+
body: JSON.stringify(logItems),
72+
}).catch(() => {});
73+
}
74+
75+
function getStacktrace() {
76+
try {
77+
throw new Error();
78+
} catch (trace) {
79+
return trace.stack;
80+
}
81+
}
82+
83+
function formatStackTrace(rawStacktrace: string): string[] {
84+
let stacktrace = rawStacktrace.split('\n');
85+
const lines = stacktrace;
86+
lines.splice(0, 3);
87+
const depth = 3;
88+
if (depth && lines.length !== depth + 1) {
89+
const shrink = lines.splice(0, depth);
90+
stacktrace = shrink;
91+
if (lines.length) stacktrace.push(`and ${lines.length} more`);
92+
} else {
93+
stacktrace = lines;
94+
}
95+
return stacktrace;
96+
}
97+
}

0 commit comments

Comments
 (0)