refactor: unify logging into @gadget/api as GadgetLog

Move the 6 duplicated logging modules (component, log, log-transport,
log-transport-console, log-transport-file, log-file) from both
gadget-code (Dtp* prefix) and gadget-drone (Gadget* prefix) into
@shad/api, using gadget-drone's GadgetLog as the canonical version.

GadgetLog now uses static configuration (consoleEnabled, defaultFile)
set by each consumer's env.ts at module scope, removing the env
dependency from the shared library. The addDefaultTransport/
removeDefaultTransport/getDefaultTransports static methods are
preserved for future real-time log transport injection.
This commit is contained in:
Rob Colbert 2026-05-08 16:03:28 -04:00
parent 58850f36e6
commit 42a47dbcb7
28 changed files with 109 additions and 345 deletions

View File

@ -6,6 +6,7 @@ import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { loadGadgetCodeConfig, resolvePath } from "@gadget/config";
import type PackageJson from "../../package.json";
import { GadgetLog, GadgetLogFile } from "@gadget/api";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -179,6 +180,12 @@ export default {
},
file: {
enabled: yamlConfig.logging?.file?.enabled === true,
path: yamlConfig.logging?.file?.path
? resolvePath(yamlConfig.logging.file.path)
: path.join(INSTALL_DIR, "logs"),
name: yamlConfig.logging?.file?.name || "gadget-code",
maxWritesPerFile: yamlConfig.logging?.file?.maxWritesPerFile || 10000,
maxFiles: yamlConfig.logging?.file?.maxFiles || 10,
},
levels: {
debug: yamlConfig.logging?.levels?.debug === true,
@ -187,3 +194,17 @@ export default {
},
},
};
// Configure GadgetLog for this package
const logFileOptions = {
basePath: yamlConfig.logging?.file?.path
? resolvePath(yamlConfig.logging.file.path)
: path.join(INSTALL_DIR, "logs"),
name: yamlConfig.logging?.file?.name || "gadget-code",
maxWritesPerFile: yamlConfig.logging?.file?.maxWritesPerFile || 10000,
maxFiles: yamlConfig.logging?.file?.maxFiles || 10,
};
const defaultLogFile = new GadgetLogFile(logFileOptions);
defaultLogFile.open();
GadgetLog.consoleEnabled = yamlConfig.logging?.console?.enabled === true;
GadgetLog.defaultFile = yamlConfig.logging?.file?.enabled === true ? defaultLogFile : undefined;

View File

@ -1,8 +0,0 @@
// src/lib/component.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
export interface DtpComponent {
get name(): string;
get slug(): string;
}

View File

@ -30,9 +30,8 @@ import { ApiClientStatus } from "../models/api-client.js";
import { CsrfToken, ICsrfToken } from "../models/csrf-token.js";
import { GadgetId, IUser } from "@gadget/api";
import { DtpComponent } from "./component.js";
import { GadgetComponent, GadgetLog } from "@gadget/api";
import { DtpPaginationParameters } from "./pagination-parameters.js";
import { DtpLog } from "./log.js";
import ApiClientService from "../services/api-client.js";
import CryptoService from "../services/crypto.js";
@ -45,8 +44,8 @@ import CryptoService from "../services/crypto.js";
* request parameters, headers, and body content, then rendering HTML page or
* JSON object responses.
*/
export abstract class DtpController implements DtpComponent {
log: DtpLog;
export abstract class DtpController implements GadgetComponent {
log: GadgetLog;
router: Router;
abstract get name(): string;
@ -54,7 +53,7 @@ export abstract class DtpController implements DtpComponent {
abstract get route(): string;
constructor() {
this.log = new DtpLog(this);
this.log = new GadgetLog(this);
this.router = Router();
this.router.use(this.middleware.bind(this));
}

View File

@ -6,8 +6,8 @@ import env from "../config/env.js";
import mongoose from "mongoose";
import { DtpLog } from "./log.js";
const log = new DtpLog({ name: "db", slug: "db" });
import { GadgetLog } from "@gadget/api";
const log = new GadgetLog({ name: "db", slug: "db" });
const DB_URL = `mongodb://${env.mongodb.host}/${env.mongodb.database}`;

View File

@ -1,60 +0,0 @@
// src/lib/log-file.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import fs from "node:fs";
import path from "node:path";
import numeral from "numeral";
import { Writable, WritableOptions } from "stream";
type StreamCallback = (error?: Error | null) => void;
export interface DtpLogFileOptions extends WritableOptions {
basePath: string;
name: string;
maxWritesPerFile: number;
maxFiles: number;
}
export class DtpLogFile extends Writable {
options: DtpLogFileOptions;
file?: fs.WriteStream;
fileIdx: number = 0;
writeCount: number = 0;
constructor(options: DtpLogFileOptions) {
super(options);
this.options = options;
}
open(): void {
fs.mkdirSync(this.options.basePath, { recursive: true });
const filename = path.join(
this.options.basePath,
`${this.options.name}.${numeral(this.fileIdx).format("000")}.log`
);
this.file = fs.createWriteStream(filename, { encoding: "utf-8" });
this.writeCount = 0;
}
_write(
chunk: unknown,
encoding: BufferEncoding,
callback: StreamCallback
): boolean {
if (!this.file) {
return false;
}
if (this.writeCount > this.options.maxWritesPerFile) {
this.file.close();
if (++this.fileIdx > this.options.maxFiles) {
this.fileIdx = 0;
}
this.open();
}
return this.file.write(chunk, encoding, callback);
}
}

View File

@ -1,62 +0,0 @@
// src/lib/log-transport-console.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import * as util from "node:util";
import dayjs from "dayjs";
import color from "ansicolor";
import { DtpLogLevel } from "./log.js";
import { DtpLogTransport } from "./log-transport.js";
import { DtpComponent } from "./component.ts";
export class DtpLogTransportConsole implements DtpLogTransport {
async writeLog(
timestamp: Date,
component: DtpComponent,
level: DtpLogLevel,
message: string,
metadata?: unknown
): Promise<void> {
let clevel = level.padEnd(5);
switch (level) {
case "debug":
clevel = color.darkGray(clevel);
break;
case "info":
clevel = color.green(clevel);
break;
case "warn":
clevel = color.yellow(clevel);
break;
case "alert":
clevel = color.red(clevel);
break;
case "error":
clevel = color.bgRed.white(clevel);
break;
case "crit":
clevel = color.bgRed.yellow(clevel);
break;
case "fatal":
clevel = color.bgRed.darkGray(clevel);
break;
}
const ccomponent = color.cyan(component.name);
const ctimestamp = color.darkGray(
dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss.SSS")
);
const cmessage = color.darkGray(message);
if (metadata) {
console.log(
`${ctimestamp} ${clevel} ${ccomponent} ${cmessage}`,
util.inspect(metadata, false, Infinity, true)
);
} else {
console.log(`${ctimestamp} ${clevel} ${ccomponent} ${cmessage}`);
}
}
}

View File

@ -1,45 +0,0 @@
// src/lib/log-transport-file.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import util from "node:util";
import { Writable } from "node:stream";
import dayjs from "dayjs";
import { DtpLogLevel } from "./log.js";
import { DtpLogTransport } from "./log-transport.js";
import { DtpComponent } from "./component.ts";
export class DtpLogTransportFile implements DtpLogTransport {
file: Writable;
constructor(file: Writable) {
this.file = file;
}
async writeLog(
timestamp: Date,
component: DtpComponent,
level: DtpLogLevel,
message: string,
metadata?: unknown,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const stimestamp = dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss.SSS");
let sLogMessage = `${stimestamp} ${level} ${component.slug} ${message}`;
if (metadata) {
sLogMessage += " " + util.inspect(metadata, false, Infinity, false);
}
const chunk = Buffer.from(sLogMessage + "\n");
this.file.write(chunk, (error: Error | null | undefined): void => {
if (error) {
return reject(error);
}
return resolve();
});
});
}
}

View File

@ -1,16 +0,0 @@
// src/lib/log-transport.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { DtpComponent } from "./component.ts";
import { DtpLogLevel } from "./log.js";
export abstract class DtpLogTransport {
abstract writeLog(
timestamp: Date,
component: DtpComponent,
level: DtpLogLevel,
message: string,
metadata?: unknown
): Promise<void>;
}

View File

@ -1,81 +0,0 @@
// src/lib/log.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import env from "../config/env.js";
import { DtpComponent } from "./component.ts";
import { DtpLogTransportConsole } from "./log-transport-console.js";
import { DtpLogTransportFile } from "./log-transport-file.js";
import { DtpLogTransport } from "./log-transport.js";
import { DtpLogFile } from "./log-file.js";
export enum DtpLogLevel {
debug = "debug",
info = "info",
warn = "warn",
alert = "alert",
error = "error",
crit = "crit",
fatal = "fatal",
}
export class DtpLog {
component: DtpComponent;
transports: Array<DtpLogTransport> = [];
constructor(component: DtpComponent, file?: DtpLogFile) {
this.component = component;
if (env.log.console.enabled) {
this.transports.push(new DtpLogTransportConsole());
}
if (env.log.file.enabled && file) {
this.transports.push(new DtpLogTransportFile(file));
}
}
async debug(message: string, metadata?: unknown) {
if (!env.log.levels.debug) {
return;
}
return this.writeLog(DtpLogLevel.debug, message, metadata);
}
async info(message: string, metadata?: unknown) {
if (!env.log.levels.info) {
return;
}
return this.writeLog(DtpLogLevel.info, message, metadata);
}
async warn(message: string, metadata?: unknown) {
if (!env.log.levels.warn) {
return;
}
return this.writeLog(DtpLogLevel.warn, message, metadata);
}
async alert(message: string, metadata?: unknown) {
return this.writeLog(DtpLogLevel.alert, message, metadata);
}
async error(message: string, metadata?: unknown) {
return this.writeLog(DtpLogLevel.error, message, metadata);
}
async crit(message: string, metadata?: unknown) {
return this.writeLog(DtpLogLevel.crit, message, metadata);
}
async fatal(message: string, metadata?: unknown) {
this.writeLog(DtpLogLevel.fatal, message, metadata);
}
async writeLog(level: DtpLogLevel, message: string, metadata?: unknown) {
const NOW = new Date();
for (const transport of this.transports) {
transport.writeLog(NOW, this.component, level, message, metadata);
}
}
}

View File

@ -2,16 +2,15 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { DtpComponent } from "./component.js";
import { DtpLog } from "./log.js";
import { GadgetComponent, GadgetLog } from "@gadget/api";
export abstract class DtpProcess implements DtpComponent {
log: DtpLog;
export abstract class DtpProcess implements GadgetComponent {
log: GadgetLog;
abstract get name(): string;
abstract get slug(): string;
constructor() {
this.log = new DtpLog(this);
this.log = new GadgetLog(this);
}
}

View File

@ -5,8 +5,8 @@
import env from "../config/env.js";
import { Redis } from "ioredis";
import { DtpLog } from "./log.js";
const log = new DtpLog({ name: "redis", slug: "redis" });
import { GadgetLog } from "@gadget/api";
const log = new GadgetLog({ name: "redis", slug: "redis" });
log.info("connecting to Redis", {
host: env.redis.host,

View File

@ -4,17 +4,16 @@
// import env from "../config/env.js";
import { DtpComponent } from "./component.js";
import { DtpLog } from "./log.js";
import { GadgetComponent, GadgetLog } from "@gadget/api";
export abstract class DtpService implements DtpComponent {
log: DtpLog;
export abstract class DtpService implements GadgetComponent {
log: GadgetLog;
abstract get name(): string;
abstract get slug(): string;
constructor() {
this.log = new DtpLog(this);
this.log = new GadgetLog(this);
}
abstract start(): Promise<void>;

View File

@ -9,7 +9,7 @@ import {
ServerToClientEvents,
SocketData,
} from "@gadget/api";
import { DtpLog } from "./log.js";
import { GadgetLog } from "@gadget/api";
export enum SocketSessionType {
Code = "code",
@ -23,7 +23,7 @@ export type GadgetSocket = Socket<
SocketData
>;
export abstract class SocketSession {
protected log: DtpLog;
protected log: GadgetLog;
protected _socket: GadgetSocket;
protected _user: IUser;
@ -38,7 +38,7 @@ export abstract class SocketSession {
protected abstract type: SocketSessionType;
constructor(socket: GadgetSocket, user: IUser) {
this.log = new DtpLog({
this.log = new GadgetLog({
name: "SocketSession",
slug: "lib:socket-session",
});

View File

@ -5,11 +5,10 @@
import env from "../config/env.js";
import BullQueue from "bull";
import { DtpLog } from "./log.js";
import { DtpComponent } from "./component.js";
import { GadgetComponent, GadgetLog } from "@gadget/api";
export abstract class DtpWorker implements DtpComponent {
log: DtpLog;
export abstract class DtpWorker implements GadgetComponent {
log: GadgetLog;
jobQueue: BullQueue.Queue | undefined;
abstract get name(): string;
@ -17,7 +16,7 @@ export abstract class DtpWorker implements DtpComponent {
abstract get queueName(): string;
constructor() {
this.log = new DtpLog(this);
this.log = new GadgetLog(this);
}
async start(): Promise<void> {

View File

@ -31,8 +31,7 @@ import numeral from "numeral";
type SameSiteOption = boolean | "lax" | "strict" | "none" | undefined;
import { DtpLog } from "./lib/log.js";
import { DtpComponent } from "./lib/component.js";
import { GadgetComponent, GadgetLog } from "@gadget/api";
import { User } from "./models/user.js";
@ -50,8 +49,8 @@ import {
} from "./services/index.js";
import { SessionType } from "./services/session.js";
class DtpWebAppServer implements DtpComponent {
private log: DtpLog;
class DtpWebAppServer implements GadgetComponent {
private log: GadgetLog;
private app?: express.Application;
private server?: http.Server;
@ -64,7 +63,7 @@ class DtpWebAppServer implements DtpComponent {
}
constructor() {
this.log = new DtpLog(this);
this.log = new GadgetLog(this);
}
async start(): Promise<void> {

View File

@ -6,6 +6,7 @@ import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { loadGadgetDroneConfig, resolvePath } from "@gadget/config";
import type PackageJson from "../../package.json";
import { GadgetLog, GadgetLogFile } from "@gadget/api";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -73,4 +74,19 @@ export default {
},
},
};
// Configure GadgetLog for this package
const logFileOptions = {
basePath: yamlConfig.logging?.file?.path
? resolvePath(yamlConfig.logging.file.path)
: path.join(INSTALL_DIR, "logs"),
name: yamlConfig.logging?.file?.name || "gadget-drone",
maxWritesPerFile: yamlConfig.logging?.file?.maxWritesPerFile || 10000,
maxFiles: yamlConfig.logging?.file?.maxFiles || 10,
};
const defaultLogFile = new GadgetLogFile(logFileOptions);
defaultLogFile.open();
GadgetLog.consoleEnabled = yamlConfig.logging?.console?.enabled === true;
GadgetLog.defaultFile = yamlConfig.logging?.file?.enabled === true ? defaultLogFile : undefined;
/* eslint-enable no-process-env */

View File

@ -2,8 +2,7 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { GadgetComponent } from "./component.ts";
import { GadgetLog } from "./log.ts";
import { GadgetComponent, GadgetLog } from "@gadget/api";
export abstract class GadgetProcess implements GadgetComponent {
protected log: GadgetLog;

View File

@ -4,8 +4,7 @@
// import env from "../config/env.js";
import { GadgetComponent } from "./component.js";
import { GadgetLog } from "./log.js";
import { GadgetComponent, GadgetLog } from "@gadget/api";
export abstract class GadgetService implements GadgetComponent {
protected log: GadgetLog;

View File

@ -19,10 +19,14 @@
"author": "Rob Colbert",
"license": "Apache-2.0",
"dependencies": {
"mongoose": "^8.16.1"
"ansicolor": "^2.0.3",
"dayjs": "^1.11.20",
"mongoose": "^8.16.1",
"numeral": "^2.0.6"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/numeral": "^2.0.5",
"typescript": "^6.0.3"
}
}

View File

@ -3,6 +3,12 @@
// All Rights Reserved
export * from "./lib/gadget-id.ts";
export * from "./lib/component.ts";
export * from "./lib/log.ts";
export * from "./lib/log-transport.ts";
export * from "./lib/log-transport-console.ts";
export * from "./lib/log-transport-file.ts";
export * from "./lib/log-file.ts";
/*
* Data Model Interfaces

View File

@ -2,8 +2,6 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import env from "../config/env.js";
import fs from "node:fs";
import path from "node:path";
@ -60,14 +58,3 @@ export class GadgetLogFile extends Writable {
return this.file.write(chunk, encoding, callback);
}
}
const defaultLogFileOptions = {
basePath: env.log.file.path || "/var/log/gadget-code",
name: env.log.file.name || env.pkg.name,
maxWritesPerFile: env.log.file.maxWritesPerFile,
maxFiles: env.log.file.maxFiles,
};
const defaultLogFile = new GadgetLogFile(defaultLogFileOptions);
defaultLogFile.open();
export { defaultLogFile };

View File

@ -7,8 +7,8 @@ import * as util from "node:util";
import dayjs from "dayjs";
import color from "ansicolor";
import { GadgetLogLevel } from "./log.js";
import { GadgetLogTransport } from "./log-transport.js";
import { GadgetLogLevel } from "./log.ts";
import { GadgetLogTransport } from "./log-transport.ts";
import { GadgetComponent } from "./component.ts";
export class GadgetLogTransportConsole implements GadgetLogTransport {

View File

@ -7,8 +7,8 @@ import { Writable } from "node:stream";
import dayjs from "dayjs";
import { GadgetLogLevel } from "./log.js";
import { GadgetLogTransport } from "./log-transport.js";
import { GadgetLogLevel } from "./log.ts";
import { GadgetLogTransport } from "./log-transport.ts";
import { GadgetComponent } from "./component.ts";
export class GadgetLogTransportFile implements GadgetLogTransport {

View File

@ -3,7 +3,7 @@
// Licensed under the Apache License, Version 2.0
import { GadgetComponent } from "./component.ts";
import { GadgetLogLevel } from "./log.js";
import { GadgetLogLevel } from "./log.ts";
export abstract class GadgetLogTransport {
abstract writeLog(

View File

@ -2,13 +2,11 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import env from "../config/env.js";
import { GadgetComponent } from "./component.ts";
import { GadgetLogTransportConsole } from "./log-transport-console.js";
import { GadgetLogTransportFile } from "./log-transport-file.js";
import { GadgetLogTransport } from "./log-transport.js";
import { GadgetLogFile, defaultLogFile } from "./log-file.js";
import { GadgetLogTransportConsole } from "./log-transport-console.ts";
import { GadgetLogTransportFile } from "./log-transport-file.ts";
import { GadgetLogTransport } from "./log-transport.ts";
import { GadgetLogFile } from "./log-file.ts";
export enum GadgetLogLevel {
debug = "debug",
@ -25,18 +23,21 @@ export class GadgetLog {
transports: GadgetLogTransport[] = [];
private static defaultTransports: GadgetLogTransport[] = [];
private file: GadgetLogFile;
static consoleEnabled = true;
static defaultFile: GadgetLogFile | undefined;
constructor(component: GadgetComponent, file?: GadgetLogFile) {
this.component = component;
this.file = file || defaultLogFile;
this.transports.push(...GadgetLog.defaultTransports);
if (env.log.console.enabled) {
if (GadgetLog.consoleEnabled) {
this.transports.push(new GadgetLogTransportConsole());
}
if (env.log.file.enabled) {
this.transports.push(new GadgetLogTransportFile(this.file));
const fileInstance = file || GadgetLog.defaultFile;
if (fileInstance) {
this.transports.push(new GadgetLogTransportFile(fileInstance));
}
}
@ -56,23 +57,17 @@ export class GadgetLog {
}
async debug(message: string, metadata?: unknown) {
if (!env.log.levels.debug) {
if (!GadgetLog.consoleEnabled) {
return;
}
return this.writeLog(GadgetLogLevel.debug, message, metadata);
}
async info(message: string, metadata?: unknown) {
if (!env.log.levels.info) {
return;
}
return this.writeLog(GadgetLogLevel.info, message, metadata);
}
async warn(message: string, metadata?: unknown) {
if (!env.log.levels.warn) {
return;
}
return this.writeLog(GadgetLogLevel.warn, message, metadata);
}

View File

@ -67,6 +67,8 @@ export interface GadgetCodeConfig {
enabled?: boolean;
path?: string;
name?: string;
maxWritesPerFile?: number;
maxFiles?: number;
};
https?: {
enabled?: boolean;

View File

@ -343,13 +343,25 @@ importers:
packages/api:
dependencies:
ansicolor:
specifier: ^2.0.3
version: 2.0.3
dayjs:
specifier: ^1.11.20
version: 1.11.20
mongoose:
specifier: ^8.16.1
version: 8.23.1
numeral:
specifier: ^2.0.6
version: 2.0.6
devDependencies:
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@types/numeral':
specifier: ^2.0.5
version: 2.0.5
typescript:
specifier: ^6.0.3
version: 6.0.3