diff --git a/.github/workflows/publish-beta-version.yml b/.github/workflows/publish-beta-version.yml new file mode 100644 index 0000000..4a5f656 --- /dev/null +++ b/.github/workflows/publish-beta-version.yml @@ -0,0 +1,30 @@ +name: Publish Beta Version + +on: + push: + tags: + - '*.*.*' + +jobs: + build: + name: tsc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: 'cat package.json' + run: cat ./package.json + - name: install node v22 + uses: actions/setup-node@v2 + with: + registry-url: 'https://registry.npmjs.org' + node-version: 22 + - name: npm install + run: npm install + - name: tsc + run: npm run build + - name: publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public + tag: 'beta' diff --git a/.github/workflows/publish-module.yml b/.github/workflows/publish-module.yml new file mode 100644 index 0000000..38bc91e --- /dev/null +++ b/.github/workflows/publish-module.yml @@ -0,0 +1,27 @@ +on: + push: + branches: + - main + +jobs: + build: + name: tsc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: 'cat package.json' + run: cat ./package.json + - name: install node v22 + uses: actions/setup-node@v2 + with: + registry-url: 'https://registry.npmjs.org' + node-version: 22 + - name: npm install + run: npm install + - name: tsc + run: npm run build + - name: publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + access: public diff --git a/lib/Logger.ts b/lib/Logger.ts index 6ee4eed..f9d862e 100644 --- a/lib/Logger.ts +++ b/lib/Logger.ts @@ -25,6 +25,18 @@ const DEFAULT_TIMER_GC_INTERVAL = 60000; * Logger module class. */ export class Logger implements LoggerMethods { + static globalAttributes: LogContext['attributes'] = {}; + + /** + * Sets global attributes to be included in every log output. + * Subsequent calls replace any previously set global attributes rather than merging them. + * + * @param data Attributes shared by all logger instances. + */ + static setGlobalAttributes(data: LogContext['attributes']): void { + Logger.globalAttributes = data; + } + /** * Temporary storage for timers. */ @@ -68,18 +80,20 @@ export class Logger implements LoggerMethods { /** * Logger module class constructor. * - * @param opts Logger module configuration options. - * @param opts.tag Tag with which the logger is being created. - * @param opts.client Underlying abstract logger to override console. - * @param opts.noServer Disable the embedded http server for runtime actions. - * @param opts.allowExit Allow process to exit naturally (uses server.unref()). - * @param opts.timerTTL TTL for timers in ms (default: 10min). Set to 0 to disable cleanup. + * @param opts Logger module configuration options. + * @param opts.tag Tag with which the logger is being created. + * @param opts.client Underlying abstract logger to override console. + * @param opts.noServer Disable the embedded http server for runtime actions. + * @param opts.attributes Attributes to add extra fields with fixed value. + * @param opts.allowExit Allow process to exit naturally (uses server.unref()). + * @param opts.timerTTL TTL for timers in ms (default: 10min). Set to 0 to disable cleanup. */ public constructor( opts: { tag?: string; client?: AbstractLogger; noServer?: boolean; + attributes?: LogContext['attributes']; allowExit?: boolean; timerTTL?: number; } = {} @@ -88,7 +102,8 @@ export class Logger implements LoggerMethods { this, output(), opts.client ?? console, - opts.tag + opts.tag, + opts.attributes ); this.configure(level()); @@ -476,18 +491,21 @@ export class Logger implements LoggerMethods { /** * Factory function to create tagged Logger instance. * - * @param tag Tag with which the logger is being created. - * @param opts.client Underlying abstract logger to override console. - * @param opts.noServer Disable the embedded http server for runtime actions. - * @param opts.allowExit Allow process to exit naturally (uses server.unref()). - * @param opts.timerTTL TTL for timers in ms (default: 10min). Set to 0 to disable cleanup. - * @returns Logger instace + * @param tag Tag with which the logger is being created. + * @param opts Optional logger configuration. + * @param opts.client Underlying abstract logger to override console. + * @param opts.noServer Disable the embedded http server for runtime actions. + * @param opts.attributes Adds fixed fields to every log emitted by the instance. + * @param opts.allowExit Allow process to exit naturally (uses server.unref()). + * @param opts.timerTTL TTL for timers in ms (default: 10min). Set to 0 to disable cleanup. + * @returns Logger instance. */ export function createLogger( tag?: string, opts: { client?: AbstractLogger; noServer?: boolean; + attributes?: LogContext['attributes']; allowExit?: boolean; timerTTL?: number; } = {} diff --git a/lib/format/index.ts b/lib/format/index.ts index e154459..3ea15c5 100644 --- a/lib/format/index.ts +++ b/lib/format/index.ts @@ -4,27 +4,30 @@ import { LogWrapper } from '../util/type/LogWrapper'; import { Outputs } from '../util/enum/Outputs'; import { text } from './text'; import { json } from './json'; +import { LogContext } from '../util/interface/LogContext'; /** * Method for retrieving the abstract logging method. * - * @param output The output format (text, json, ...). - * @param logger The underlying logging client. - * @param tag Tag to mark logged output. + * @param output The output format (text, json, ...). + * @param logger The underlying logging client. + * @param tag Tag to mark logged output. + * @param attributes Local labels held in instance to show in instance context. * @returns The logging method. */ export function getLogWrapper( this: TScope, output: (typeof Outputs)[number], logger: AbstractLogger, - tag?: string + tag?: string, + attributes?: LogContext['attributes'] ): LogWrapper { switch (output) { case 'text': - return text.call(this, logger, tag); + return text.call(this, logger, tag, attributes); case 'json': - return json.call(this, logger, tag); + return json.call(this, logger, tag, attributes); default: throw new Error(`Log output '${output}' is not supported.`); diff --git a/lib/format/json.ts b/lib/format/json.ts index 6d76429..cfbdcb2 100644 --- a/lib/format/json.ts +++ b/lib/format/json.ts @@ -9,6 +9,7 @@ import { getCircularReplacer, normalize } from '../util/Helpers'; +import { LogContext } from '../util/interface/LogContext'; /** * Creates a structured JSON log object. @@ -16,13 +17,15 @@ import { * @param level The log level identifier. * @param timestamp Function that returns an object with datetime specific properties. * @param tag Optional tag to mark logged output. + * @param attributes Optional labels to show in every instance log. * @returns The structured log object with `toString` and `push` methods. */ function toStructuredJsonLog( this: TScope, level: LevelTag, timestamp: () => { timestamp?: string }, - tag?: string + tag?: string, + attributes?: LogContext['attributes'] ) { const data: Record = {}; const logLevelKey = @@ -30,32 +33,39 @@ function toStructuredJsonLog( ? ('warn' as const) : (level.toLowerCase() as keyof typeof LogLevels); - const structuredData = { - ...timestamp(), - ...this.getContext(), - tag, - data /** @deprecated Use 'body' instead. */, - level /** @deprecated Use 'severityText' instead. */, - severityText: level, - severityNumber: LogLevels[logLevelKey], - body: data - }; - let i = 0; return { - toString() { + toString: () => { + const structuredData = { + ...timestamp(), + ...this.getContext(), + ...(Logger.globalAttributes ?? {}), + ...(attributes ?? {}), + tag, + data /** @deprecated Use 'body' instead. */, + level /** @deprecated Use 'severityText' instead. */, + severityText: level, + severityNumber: LogLevels[logLevelKey], + body: data + }; + try { return JSON.stringify(structuredData, getCircularReplacer()); } catch (e) { - structuredData.data = structuredData.body = - '[OZLogger internal] - Unable to serialize log data'; - return JSON.stringify(structuredData, getCircularReplacer()); + const fallback = { + ...timestamp(), + tag, + severityText: level, + severityNumber: LogLevels[logLevelKey], + body: '[OZLogger internal] - Unable to serialize log data' + }; + return JSON.stringify(fallback); } }, push(value: unknown) { // Setting on data object works as a proxy to the - // structured data's "body" and "data" properties. + // structured data's "body" property. data[i++] = value; } }; @@ -64,20 +74,28 @@ function toStructuredJsonLog( /** * Formatting method for JSON output. * - * @param logger The underlying logging client. - * @param tag Tag to mark logged output. + * @param logger The underlying logging client. + * @param tag Tag to mark logged output. + * @param attributes Instance optional labels to show in every log from instance. * @returns The logging method. */ export function json( this: TScope, logger: AbstractLogger, - tag?: string + tag?: string, + attributes?: LogContext['attributes'] ): LogWrapper { const now = datetime<{ timestamp?: string }>(); const paint = colorized(); return (level: LevelTag, ...args: unknown[]) => { - const payload = toStructuredJsonLog.call(this, level, now, tag); + const payload = toStructuredJsonLog.call( + this, + level, + now, + tag, + attributes + ); for (const arg of args) { payload.push(normalize(arg)); diff --git a/lib/format/text.ts b/lib/format/text.ts index 10a0016..31f1534 100644 --- a/lib/format/text.ts +++ b/lib/format/text.ts @@ -1,27 +1,81 @@ import { Logger } from '../Logger'; import { LogWrapper } from '../util/type/LogWrapper'; import { AbstractLogger } from '../util/type/AbstractLogger'; -import { colorized, datetime, stringify } from '../util/Helpers'; +import { + colorized, + datetime, + getCircularReplacer, + normalize, + stringify +} from '../util/Helpers'; import { LevelTag } from '../util/enum/LevelTags'; +import { LogContext } from '../util/interface/LogContext'; + +/** + * Converts an attribute value to its string representation for text output. + * + * @param value The attribute value to format. + * @returns The formatted string representation. + */ +function formatTextAttributeValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'undefined') return 'undefined'; + + const normalized = normalize(value); + + if (typeof normalized === 'string') return normalized; + + try { + return JSON.stringify(normalized, getCircularReplacer()); + } catch { + return '[Unserializable]'; + } +} + +/** + * Merges global and instance attributes into a key=value text string. + * + * @param attributes Instance-level attributes to merge with global ones. + * @returns Formatted string of key=value pairs, or empty string if none. + */ +function formatTextAttributes(attributes?: LogContext['attributes']): string { + const mergedAttributes = { + ...Logger.globalAttributes, + ...attributes + }; + const entries = Object.entries(mergedAttributes); + + if (entries.length === 0) return ''; + + return entries + .map(([key, value]) => `${key}=${formatTextAttributeValue(value)}`) + .join(' '); +} /** * Formatting method for text output. * - * @param logger The underlying logging client. - * @param tag Tag to mark logged output. + * @param logger The underlying logging client. + * @param tag Tag to mark logged output. + * @param attributes Instance attributes included in the text output. * @returns The logging method. */ export function text( this: TScope, logger: AbstractLogger, - tag?: string + tag?: string, + attributes?: LogContext['attributes'] ): LogWrapper { const now = datetime(); const paint = colorized(); return (level: LevelTag, ...args: unknown[]) => { const data = args.map((arg) => stringify(arg)).join(' '); + const labels = formatTextAttributes(attributes); + const message = [`${now()}[${level}]`, tag ?? '', labels, data] + .filter(Boolean) + .join(' '); - logger.log(paint[level](`${now()}[${level}] ${tag ?? ''} ${data}`)); + logger.log(paint[level](message)); }; } diff --git a/package-lock.json b/package-lock.json index b87b093..30936a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ozmap/logger", - "version": "0.2.8", + "version": "0.3.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ozmap/logger", - "version": "0.2.8", + "version": "0.3.0-beta.5", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.0" diff --git a/package.json b/package.json index 21bc821..56a949e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ozmap/logger", - "version": "0.2.8", + "version": "0.3.0-beta.5", "description": "DevOZ logger module.", "main": "dist/index.js", "scripts": { diff --git a/tests/format.test.ts b/tests/format.test.ts index d4257a4..2489896 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -19,6 +19,7 @@ describe('JSON Formatter', () => { afterEach(() => { delete process.env.OZLOGGER_OUTPUT; delete process.env.OZLOGGER_LEVEL; + Logger.globalAttributes = {}; }); test('should output valid JSON', () => { @@ -107,6 +108,33 @@ describe('JSON Formatter', () => { expect(output.body['1']).toBe(false); }); + test('should merge global and instance attributes in JSON output', () => { + Logger.setGlobalAttributes({ + tenant: 'ozmap', + shared: 'global', + featureFlag: true + }); + + logger = createLogger('JSON-ATTR-TEST', { + client: mockLogger, + noServer: true, + attributes: { + job: 'history', + shared: 'local', + metadata: { scope: 'instance' } + } + }); + + logger.audit('payload'); + + const output = JSON.parse(logged[0]); + expect(output.tenant).toBe('ozmap'); + expect(output.job).toBe('history'); + expect(output.featureFlag).toBe(true); + expect(output.shared).toBe('local'); + expect(output.metadata).toEqual({ scope: 'instance' }); + }); + describe('Log levels', () => { test('debug level', () => { logger.debug('debug msg'); @@ -163,6 +191,7 @@ describe('Text Formatter', () => { afterEach(() => { delete process.env.OZLOGGER_OUTPUT; delete process.env.OZLOGGER_LEVEL; + Logger.globalAttributes = {}; }); test('should output text format', () => { @@ -188,6 +217,22 @@ describe('Text Formatter', () => { logger.info('one', 'two', 'three'); expect(logged[0]).toContain('one two three'); }); + + test('should include global and instance attributes in text output', () => { + Logger.setGlobalAttributes({ tenant: 'ozmap', shared: 'global' }); + logger = createLogger('TEXT-ATTR-TEST', { + client: mockLogger, + noServer: true, + attributes: { job: 'history', shared: 'local', enabled: true } + }); + + logger.info('test message'); + + expect(logged[0]).toContain('tenant=ozmap'); + expect(logged[0]).toContain('job=history'); + expect(logged[0]).toContain('shared=local'); + expect(logged[0]).toContain('enabled=true'); + }); }); describe('Text Formatter with datetime', () => { @@ -210,6 +255,7 @@ describe('Text Formatter with datetime', () => { delete process.env.OZLOGGER_OUTPUT; delete process.env.OZLOGGER_LEVEL; delete process.env.OZLOGGER_DATETIME; + Logger.globalAttributes = {}; }); test('should include timestamp in text output', () => { @@ -239,6 +285,7 @@ describe('JSON Formatter with datetime', () => { delete process.env.OZLOGGER_OUTPUT; delete process.env.OZLOGGER_LEVEL; delete process.env.OZLOGGER_DATETIME; + Logger.globalAttributes = {}; }); test('should include timestamp in JSON output', () => { @@ -304,6 +351,7 @@ describe('Format fallback behavior', () => { delete process.env.OZLOGGER_OUTPUT; delete process.env.OZLOGGER_LEVEL; + Logger.globalAttributes = {}; }); }); @@ -328,13 +376,17 @@ describe('JSON Formatter error handling', () => { logger.info(problematicObj); - // Should still log something (fallback message replaces body entirely) + // Should still log something (minimal safe fallback payload) expect(logged.length).toBe(1); const output = JSON.parse(logged[0]); - // When stringify fails, body is replaced with the error message string + // Fallback builds a minimal guaranteed-serializable payload expect(output.body).toContain('Unable to serialize'); + // Fallback should not include unserializable attributes/context + expect(output.severityText).toBe('INFO'); + expect(output.tag).toBe('STRINGIFY-FAIL'); delete process.env.OZLOGGER_OUTPUT; delete process.env.OZLOGGER_LEVEL; + Logger.globalAttributes = {}; }); }); diff --git a/tests/logger-core.test.ts b/tests/logger-core.test.ts index 6ca14dd..f041494 100644 --- a/tests/logger-core.test.ts +++ b/tests/logger-core.test.ts @@ -37,10 +37,15 @@ describe('Logger Core', () => { expect(logged[0]).toContain('ms'); }); - test('should throw when timer ID already exists', () => { + test('should warn and overwrite when timer ID already exists', () => { logger.time('duplicate'); - expect(() => logger.time('duplicate')).toThrow( - 'Identifier duplicate is in use' + expect(() => logger.time('duplicate')).not.toThrow(); + + expect(logged).toHaveLength(1); + const output = JSON.parse(logged[0]); + expect(output.severityText).toBe('WARNING'); + expect(output.body['0']).toContain( + 'Identifier duplicate is already in use' ); });