keryx 0.7.0 → 0.8.1

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/api.ts CHANGED
@@ -18,7 +18,7 @@ export {
18
18
  } from "./classes/Channel";
19
19
  export { Connection } from "./classes/Connection";
20
20
  export { Initializer } from "./classes/Initializer";
21
- export { Logger } from "./classes/Logger";
21
+ export { LogFormat, Logger } from "./classes/Logger";
22
22
  export { Server } from "./classes/Server";
23
23
  export type {
24
24
  FanOutJob,
@@ -7,6 +7,7 @@ import type { SessionData } from "../initializers/session";
7
7
  import type { RateLimitInfo } from "../middleware/rateLimit";
8
8
  import { isSecret } from "../util/zodMixins";
9
9
  import type { Action, ActionParams } from "./Action";
10
+ import { LogFormat } from "./Logger";
10
11
  import { ErrorType, TypedError } from "./TypedError";
11
12
 
12
13
  /**
@@ -21,6 +22,8 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
21
22
  identifier: string;
22
23
  /** Unique connection ID (UUID by default). Used as the key in `api.connections`. */
23
24
  id: string;
25
+ /** Session ID used for Redis session lookup. Defaults to `id` but may differ for WebSocket connections where the session cookie differs from the connection map key. */
26
+ sessionId: string;
24
27
  /** The connection's session data, lazily loaded on first action invocation. */
25
28
  session?: SessionData<T>;
26
29
  /** Set of channel names this connection is currently subscribed to. */
@@ -41,16 +44,19 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
41
44
  * @param identifier - Human-readable identifier, typically the remote IP address.
42
45
  * @param id - Unique connection ID. Defaults to a random UUID.
43
46
  * @param rawConnection - The underlying transport handle (e.g., Bun `ServerWebSocket`).
47
+ * @param sessionId - Session ID for Redis session lookup. Defaults to `id`. Use a different value when the connection map key should differ from the session cookie (e.g., WebSocket connections).
44
48
  */
45
49
  constructor(
46
50
  type: string,
47
51
  identifier: string,
48
52
  id = randomUUID() as string,
49
53
  rawConnection: any = undefined,
54
+ sessionId?: string,
50
55
  ) {
51
56
  this.type = type;
52
57
  this.identifier = identifier;
53
58
  this.id = id;
59
+ this.sessionId = sessionId ?? id;
54
60
  this.sessionLoaded = false;
55
61
  this.subscriptions = new Set();
56
62
  this.rawConnection = rawConnection;
@@ -152,35 +158,28 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
152
158
  });
153
159
  }
154
160
 
155
- // Note: we want the params object to remain on the same line as the message, so we stringify
156
- const sanitizedParams = sanitizeParams(params, action);
157
- const loggingParams = config.logger.colorize
158
- ? colors.gray(JSON.stringify(sanitizedParams))
159
- : JSON.stringify(sanitizedParams);
160
-
161
- const statusMessage = `[ACTION:${this.type.toUpperCase()}:${loggerResponsePrefix}]`;
162
- const messagePrefix = config.logger.colorize
163
- ? loggerResponsePrefix === "OK"
164
- ? colors.bgBlue(statusMessage)
165
- : colors.bgMagenta(statusMessage)
166
- : statusMessage;
167
-
168
161
  const duration = new Date().getTime() - reqStartTime;
169
162
 
170
- const errorStack =
171
- error && error.stack
172
- ? config.logger.colorize
173
- ? "\r\n" + colors.gray(error.stack)
174
- : "\r\n" + error.stack
175
- : "";
176
-
177
- const correlationIdTag = this.correlationId
178
- ? ` [cor:${this.correlationId}]`
179
- : "";
163
+ api.observability.action.executionsTotal.add(1, {
164
+ action: actionName ?? "unknown",
165
+ status: loggerResponsePrefix === "OK" ? "success" : "error",
166
+ });
167
+ api.observability.action.duration.record(duration, {
168
+ action: actionName ?? "unknown",
169
+ });
180
170
 
181
- logger.info(
182
- `${messagePrefix} ${actionName} (${duration}ms) ${method.length > 0 ? `[${method}]` : ""} ${this.identifier}${url.length > 0 ? `(${url})` : ""}${correlationIdTag} ${error ? error : ""} ${loggingParams} ${errorStack}`,
183
- );
171
+ logAction({
172
+ actionName,
173
+ connectionType: this.type,
174
+ status: loggerResponsePrefix,
175
+ duration,
176
+ params: sanitizeParams(params, action),
177
+ method,
178
+ url,
179
+ identifier: this.identifier,
180
+ correlationId: this.correlationId,
181
+ error,
182
+ });
184
183
 
185
184
  return { response, error };
186
185
  }
@@ -334,6 +333,66 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
334
333
  }
335
334
  }
336
335
 
336
+ function logAction(opts: {
337
+ actionName: string | undefined;
338
+ connectionType: string;
339
+ status: "OK" | "ERROR";
340
+ duration: number;
341
+ params: Record<string, any>;
342
+ method: string;
343
+ url: string;
344
+ identifier: string;
345
+ correlationId: string | undefined;
346
+ error: TypedError | undefined;
347
+ }) {
348
+ if (config.logger.format === LogFormat.json) {
349
+ const data: Record<string, any> = {
350
+ action: opts.actionName,
351
+ connectionType: opts.connectionType,
352
+ status: opts.status,
353
+ duration: opts.duration,
354
+ params: opts.params,
355
+ };
356
+ if (opts.method) data.method = opts.method;
357
+ if (opts.url) data.url = opts.url;
358
+ if (opts.identifier) data.identifier = opts.identifier;
359
+ if (opts.correlationId) data.correlationId = opts.correlationId;
360
+ if (opts.error) {
361
+ data.error = opts.error.message;
362
+ data.errorType = opts.error.type;
363
+ if (opts.error.stack) data.errorStack = opts.error.stack;
364
+ }
365
+
366
+ logger.info(`action: ${opts.actionName}`, data);
367
+ } else {
368
+ const loggingParams = config.logger.colorize
369
+ ? colors.gray(JSON.stringify(opts.params))
370
+ : JSON.stringify(opts.params);
371
+
372
+ const statusMessage = `[ACTION:${opts.connectionType.toUpperCase()}:${opts.status}]`;
373
+ const messagePrefix = config.logger.colorize
374
+ ? opts.status === "OK"
375
+ ? colors.bgBlue(statusMessage)
376
+ : colors.bgMagenta(statusMessage)
377
+ : statusMessage;
378
+
379
+ const errorStack =
380
+ opts.error && opts.error.stack
381
+ ? config.logger.colorize
382
+ ? "\r\n" + colors.gray(opts.error.stack)
383
+ : "\r\n" + opts.error.stack
384
+ : "";
385
+
386
+ const correlationIdTag = opts.correlationId
387
+ ? ` [cor:${opts.correlationId}]`
388
+ : "";
389
+
390
+ logger.info(
391
+ `${messagePrefix} ${opts.actionName} (${opts.duration}ms) ${opts.method.length > 0 ? `[${opts.method}]` : ""} ${opts.identifier}${opts.url.length > 0 ? `(${opts.url})` : ""}${correlationIdTag} ${opts.error ? opts.error : ""} ${loggingParams} ${errorStack}`,
392
+ );
393
+ }
394
+ }
395
+
337
396
  const REDACTED = "[[secret]]" as const;
338
397
 
339
398
  const sanitizeParams = (params: FormData, action: Action | undefined) => {
package/classes/Logger.ts CHANGED
@@ -11,22 +11,30 @@ export enum LogLevel {
11
11
  "fatal" = "fatal",
12
12
  }
13
13
 
14
+ export enum LogFormat {
15
+ "text" = "text",
16
+ "json" = "json",
17
+ }
18
+
14
19
  /**
15
- * The Logger Class. I write to stdout or stderr, and can be colorized.
20
+ * The Logger Class. Writes to stdout/stderr in either human-readable text format
21
+ * (with optional ANSI colors) or structured NDJSON format for log aggregation systems.
16
22
  */
17
23
  export class Logger {
18
24
  /** Minimum log level to output. Messages below this level are silently dropped. */
19
25
  level: LogLevel;
20
- /** Whether to apply ANSI color codes to the output. */
26
+ /** Whether to apply ANSI color codes to the output (text format only). */
21
27
  colorize: boolean;
22
28
  /** Whether to prepend an ISO-8601 timestamp to each log line. */
23
29
  includeTimestamps: boolean;
24
- /** Indentation spaces used when JSON-stringifying the optional object argument. */
30
+ /** Indentation spaces used when JSON-stringifying the optional data argument in text mode. */
25
31
  jSONObjectParsePadding: number;
26
32
  /** When `true`, all logging is suppressed (used by CLI mode). */
27
33
  quiet: boolean;
28
34
  /** The output function — defaults to `console.log`. Override for custom transports. */
29
35
  outputStream: typeof console.log;
36
+ /** Output format: `"text"` for human-readable colored output, `"json"` for structured NDJSON. */
37
+ format: LogFormat;
30
38
 
31
39
  constructor(config: typeof configLogger) {
32
40
  this.level = config.level;
@@ -35,17 +43,23 @@ export class Logger {
35
43
  this.jSONObjectParsePadding = 4;
36
44
  this.quiet = false;
37
45
  this.outputStream = console.log;
46
+ this.format = config.format;
38
47
  }
39
48
 
40
49
  /**
41
50
  * Core logging method. Formats and writes a log line to `outputStream` if the given
42
- * level meets the minimum threshold. Optionally includes a timestamp and pretty-printed object.
51
+ * level meets the minimum threshold.
52
+ *
53
+ * In text mode, outputs a human-readable string with optional timestamp, colors, and
54
+ * pretty-printed data object. In JSON mode, outputs a single NDJSON line with structured
55
+ * fields including `timestamp`, `level`, `message`, `pid`, and any fields from `data`.
43
56
  *
44
57
  * @param level - The severity level of this log entry.
45
58
  * @param message - The log message string.
46
- * @param object - An optional object to JSON-stringify and append to the log line.
59
+ * @param data - Optional structured data to include. In text mode, JSON-stringified and
60
+ * appended to the log line. In JSON mode, merged into the output object.
47
61
  */
48
- log(level: LogLevel, message: string, object?: any) {
62
+ log(level: LogLevel, message: string, data?: any) {
49
63
  if (this.quiet) return;
50
64
 
51
65
  if (
@@ -55,84 +69,111 @@ export class Logger {
55
69
  return;
56
70
  }
57
71
 
58
- let timestamp = this.includeTimestamps ? `${new Date().toISOString()}` : "";
59
- if (this.colorize && timestamp.length > 0) {
60
- timestamp = colors.gray(timestamp);
61
- }
62
-
63
- let formattedLevel = `[${level}]`;
64
- if (this.colorize) {
65
- formattedLevel = this.colorFromLopLevel(level)(formattedLevel);
66
- }
67
-
68
- let prettyObject =
69
- object !== undefined
70
- ? JSON.stringify(object, null, this.jSONObjectParsePadding)
71
- : "";
72
- if (this.colorize && prettyObject.length > 0) {
73
- prettyObject = colors.cyan(prettyObject);
72
+ if (this.format === LogFormat.json) {
73
+ this.logJson(level, message, data);
74
+ } else {
75
+ this.logText(level, message, data);
74
76
  }
75
-
76
- this.outputStream(
77
- `${timestamp} ${formattedLevel} ${message} ${prettyObject}`,
78
- );
79
77
  }
80
78
 
81
79
  /**
82
80
  * Log a trace message.
83
81
  * @param message - The message to log.
84
- * @param object - The object to log.
82
+ * @param data - Optional structured data to include in the log entry.
85
83
  */
86
- trace(message: string, object?: any) {
87
- this.log(LogLevel.trace, message, object);
84
+ trace(message: string, data?: any) {
85
+ this.log(LogLevel.trace, message, data);
88
86
  }
89
87
 
90
88
  /**
91
89
  * Log a debug message.
92
90
  * @param message - The message to log.
93
- * @param object - The object to log.
91
+ * @param data - Optional structured data to include in the log entry.
94
92
  */
95
- debug(message: string, object?: any) {
96
- this.log(LogLevel.debug, message, object);
93
+ debug(message: string, data?: any) {
94
+ this.log(LogLevel.debug, message, data);
97
95
  }
98
96
 
99
97
  /**
100
98
  * Log an info message.
101
99
  * @param message - The message to log.
102
- * @param object - The object to log.
100
+ * @param data - Optional structured data to include in the log entry.
103
101
  */
104
- info(message: string, object?: any) {
105
- this.log(LogLevel.info, message, object);
102
+ info(message: string, data?: any) {
103
+ this.log(LogLevel.info, message, data);
106
104
  }
107
105
 
108
106
  /**
109
107
  * Log a warning.
110
108
  * @param message - The message to log.
111
- * @param object - The object to log.
109
+ * @param data - Optional structured data to include in the log entry.
112
110
  */
113
- warn(message: string, object?: any) {
114
- this.log(LogLevel.warn, message, object);
111
+ warn(message: string, data?: any) {
112
+ this.log(LogLevel.warn, message, data);
115
113
  }
116
114
 
117
115
  /**
118
116
  * Log an error.
119
117
  * @param message - The message to log.
120
- * @param object - The object to log.
118
+ * @param data - Optional structured data to include in the log entry.
121
119
  */
122
- error(message: string, object?: any) {
123
- this.log(LogLevel.error, message, object);
120
+ error(message: string, data?: any) {
121
+ this.log(LogLevel.error, message, data);
124
122
  }
125
123
 
126
124
  /**
127
125
  * Log a fatal error.
128
126
  * @param message - The message to log.
129
- * @param object - The object to log.
127
+ * @param data - Optional structured data to include in the log entry.
130
128
  */
131
- fatal(message: string, object?: any) {
132
- this.log(LogLevel.fatal, message, object);
129
+ fatal(message: string, data?: any) {
130
+ this.log(LogLevel.fatal, message, data);
131
+ }
132
+
133
+ private logText(level: LogLevel, message: string, data?: any) {
134
+ let timestamp = this.includeTimestamps ? `${new Date().toISOString()}` : "";
135
+ if (this.colorize && timestamp.length > 0) {
136
+ timestamp = colors.gray(timestamp);
137
+ }
138
+
139
+ let formattedLevel = `[${level}]`;
140
+ if (this.colorize) {
141
+ formattedLevel = this.colorFromLogLevel(level)(formattedLevel);
142
+ }
143
+
144
+ let prettyObject =
145
+ data !== undefined
146
+ ? JSON.stringify(data, null, this.jSONObjectParsePadding)
147
+ : "";
148
+ if (this.colorize && prettyObject.length > 0) {
149
+ prettyObject = colors.cyan(prettyObject);
150
+ }
151
+
152
+ this.outputStream(
153
+ `${timestamp} ${formattedLevel} ${message} ${prettyObject}`,
154
+ );
155
+ }
156
+
157
+ private logJson(level: LogLevel, message: string, data?: any) {
158
+ const entry: Record<string, any> = {
159
+ timestamp: new Date().toISOString(),
160
+ level,
161
+ message,
162
+ pid: process.pid,
163
+ };
164
+
165
+ if (data !== undefined && data !== null) {
166
+ if (typeof data === "object" && !Array.isArray(data)) {
167
+ Object.assign(entry, data);
168
+ } else {
169
+ entry.data = data;
170
+ }
171
+ }
172
+
173
+ this.outputStream(JSON.stringify(entry));
133
174
  }
134
175
 
135
- private colorFromLopLevel(level: LogLevel) {
176
+ private colorFromLogLevel(level: LogLevel) {
136
177
  switch (level) {
137
178
  case LogLevel.trace:
138
179
  return colors.gray;
package/config/index.ts CHANGED
@@ -2,6 +2,7 @@ import { configActions } from "./actions";
2
2
  import { configChannels } from "./channels";
3
3
  import { configDatabase } from "./database";
4
4
  import { configLogger } from "./logger";
5
+ import { configObservability } from "./observability";
5
6
  import { configProcess } from "./process";
6
7
  import { configRateLimit } from "./rateLimit";
7
8
  import { configRedis } from "./redis";
@@ -17,6 +18,7 @@ export const config = {
17
18
  process: configProcess,
18
19
  logger: configLogger,
19
20
  database: configDatabase,
21
+ observability: configObservability,
20
22
  redis: configRedis,
21
23
  rateLimit: configRateLimit,
22
24
  session: configSession,
package/config/logger.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { LogLevel } from "../classes/Logger";
1
+ import { LogFormat, LogLevel } from "../classes/Logger";
2
2
  import { loadFromEnvIfSet } from "../util/config";
3
3
 
4
4
  export const configLogger = {
5
5
  level: await loadFromEnvIfSet<LogLevel>("LOG_LEVEL", LogLevel.info),
6
6
  includeTimestamps: await loadFromEnvIfSet("LOG_INCLUDE_TIMESTAMPS", true),
7
7
  colorize: await loadFromEnvIfSet("LOG_COLORIZE", true),
8
+ format: await loadFromEnvIfSet<LogFormat>("LOG_FORMAT", LogFormat.text),
8
9
  };
@@ -0,0 +1,7 @@
1
+ import { loadFromEnvIfSet } from "../util/config";
2
+
3
+ export const configObservability = {
4
+ enabled: await loadFromEnvIfSet("OTEL_METRICS_ENABLED", false),
5
+ metricsRoute: await loadFromEnvIfSet("OTEL_METRICS_ROUTE", "/metrics"),
6
+ serviceName: await loadFromEnvIfSet("OTEL_SERVICE_NAME", ""),
7
+ };
@@ -21,30 +21,25 @@ export const configServerWeb = {
21
21
  "WEB_SERVER_ALLOWED_HEADERS",
22
22
  "Content-Type",
23
23
  ),
24
- staticFilesEnabled: await loadFromEnvIfSet("WEB_SERVER_STATIC_ENABLED", true),
25
- staticFilesDirectory: await loadFromEnvIfSet(
26
- "WEB_SERVER_STATIC_DIRECTORY",
27
- "assets",
28
- ),
29
- staticFilesRoute: await loadFromEnvIfSet("WEB_SERVER_STATIC_ROUTE", "/"),
30
- staticFilesCacheControl: await loadFromEnvIfSet(
31
- "WEB_SERVER_STATIC_CACHE_CONTROL",
32
- "public, max-age=3600",
33
- ),
34
- staticFilesEtag: await loadFromEnvIfSet("WEB_SERVER_STATIC_ETAG", true),
35
- websocketMaxPayloadSize: await loadFromEnvIfSet(
36
- "WS_MAX_PAYLOAD_SIZE",
37
- 65_536,
38
- ),
39
- websocketMaxMessagesPerSecond: await loadFromEnvIfSet(
40
- "WS_MAX_MESSAGES_PER_SECOND",
41
- 20,
42
- ),
43
- websocketMaxSubscriptions: await loadFromEnvIfSet(
44
- "WS_MAX_SUBSCRIPTIONS",
45
- 100,
46
- ),
47
- websocketDrainTimeout: await loadFromEnvIfSet("WS_DRAIN_TIMEOUT", 5000),
24
+ staticFiles: {
25
+ enabled: await loadFromEnvIfSet("WEB_SERVER_STATIC_ENABLED", true),
26
+ directory: await loadFromEnvIfSet("WEB_SERVER_STATIC_DIRECTORY", "assets"),
27
+ route: await loadFromEnvIfSet("WEB_SERVER_STATIC_ROUTE", "/"),
28
+ cacheControl: await loadFromEnvIfSet(
29
+ "WEB_SERVER_STATIC_CACHE_CONTROL",
30
+ "public, max-age=3600",
31
+ ),
32
+ etag: await loadFromEnvIfSet("WEB_SERVER_STATIC_ETAG", true),
33
+ },
34
+ websocket: {
35
+ maxPayloadSize: await loadFromEnvIfSet("WS_MAX_PAYLOAD_SIZE", 65_536),
36
+ maxMessagesPerSecond: await loadFromEnvIfSet(
37
+ "WS_MAX_MESSAGES_PER_SECOND",
38
+ 20,
39
+ ),
40
+ maxSubscriptions: await loadFromEnvIfSet("WS_MAX_SUBSCRIPTIONS", 100),
41
+ drainTimeout: await loadFromEnvIfSet("WS_DRAIN_TIMEOUT", 5000),
42
+ },
48
43
  includeStackInErrors: await loadFromEnvIfSet(
49
44
  "WEB_SERVER_INCLUDE_STACK_IN_ERRORS",
50
45
  (Bun.env.NODE_ENV ?? "development") !== "production",
@@ -71,6 +66,11 @@ export const configServerWeb = {
71
66
  "strict-origin-when-cross-origin",
72
67
  ),
73
68
  } as Record<string, string>,
69
+ compression: {
70
+ enabled: await loadFromEnvIfSet("WEB_COMPRESSION_ENABLED", true),
71
+ threshold: await loadFromEnvIfSet("WEB_COMPRESSION_THRESHOLD", 1024),
72
+ encodings: ["br", "gzip"] as ("br" | "gzip")[],
73
+ },
74
74
  correlationId: {
75
75
  header: await loadFromEnvIfSet("WEB_CORRELATION_ID_HEADER", "X-Request-Id"),
76
76
  trustProxy: await loadFromEnvIfSet("WEB_CORRELATION_ID_TRUST_PROXY", false),
package/index.ts CHANGED
@@ -7,6 +7,7 @@ import "./initializers/connections";
7
7
  import "./initializers/db";
8
8
  import "./initializers/mcp";
9
9
  import "./initializers/oauth";
10
+ import "./initializers/observability";
10
11
  import "./initializers/process";
11
12
  import "./initializers/pubsub";
12
13
  import "./initializers/redis";
@@ -93,6 +93,10 @@ export class Actions extends Initializer {
93
93
  });
94
94
  }
95
95
  queue = queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
96
+ api.observability.task.enqueuedTotal.add(1, {
97
+ action: actionName,
98
+ queue,
99
+ });
96
100
  return api.resque.queue.enqueue(queue, actionName, [inputs]);
97
101
  };
98
102