From 2e36df33c5feb3a6a15df6b03c7493ca67a04888 Mon Sep 17 00:00:00 2001 From: Romain Lenzotti Date: Sat, 3 Oct 2026 18:12:17 +0200 Subject: [PATCH 1/2] test: cover the logstash http and udp appenders Co-Authored-By: Claude Opus 5.5 --- packages/logstash-http/package.json | 4 +- .../src/LogStashHttpAppender.spec.ts | 156 ++++++++++++++++++ packages/logstash-http/vitest.config.mts | 19 +++ packages/logstash-udp/package.json | 4 +- .../src/LogStashUdpAppender.spec.ts | 112 +++++++++++++ packages/logstash-udp/vitest.config.mts | 19 +++ 6 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 packages/logstash-http/src/LogStashHttpAppender.spec.ts create mode 100644 packages/logstash-http/vitest.config.mts create mode 100644 packages/logstash-udp/src/LogStashUdpAppender.spec.ts create mode 100644 packages/logstash-udp/vitest.config.mts diff --git a/packages/logstash-http/package.json b/packages/logstash-http/package.json index d5fbf6cd..2bbe5a43 100644 --- a/packages/logstash-http/package.json +++ b/packages/logstash-http/package.json @@ -38,7 +38,9 @@ }, "scripts": { "build": "yarn run build:ts", - "build:ts": "tsc --build tsconfig.esm.json" + "build:ts": "tsc --build tsconfig.esm.json", + "test": "vitest run", + "test:ci": "vitest run --coverage.thresholds.autoUpdate=true" }, "dependencies": { "axios": ">=1.20.0", diff --git a/packages/logstash-http/src/LogStashHttpAppender.spec.ts b/packages/logstash-http/src/LogStashHttpAppender.spec.ts new file mode 100644 index 00000000..31818f88 --- /dev/null +++ b/packages/logstash-http/src/LogStashHttpAppender.spec.ts @@ -0,0 +1,156 @@ +import {AppendersRegistry, Logger} from "@tsed/logger"; +import axios from "axios"; +import axiosRetry from "axios-retry"; +import {vi} from "vitest"; + +import {LogStashHttpAppender} from "./LogStashHttpAppender.js"; + +vi.mock("axios"); +vi.mock("axios-retry"); + +function createLogger(options: Record = {}) { + const client = vi.fn().mockResolvedValue({status: 200, data: {errors: false}}); + + vi.mocked(axios.create).mockReturnValue(client as never); + + const logger = new Logger("test"); + + logger.appenders.set("logstash", { + type: "logstash-http", + options: { + url: "http://localhost:9200/_bulk", + application: "logstash-test", + logType: "application", + logChannel: "node", + ...options + } + }); + + return {logger, client}; +} + +function getLines(client: ReturnType, call = 0) { + return client.mock.calls[call][0].data + .split("\n") + .filter(Boolean) + .map((line: string) => JSON.parse(line)); +} + +describe("LogStashHttpAppender", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should be registered under the logstash-http name", () => { + expect(AppendersRegistry.get("logstash-http")!.provide).toBe(LogStashHttpAppender); + }); + + it("should create the HTTP client from the options", () => { + createLogger({ + auth: {username: "user", password: "pass"}, + timeout: 1000, + params: {pipeline: "main"}, + headers: {"x-api-key": "key"}, + retryOptions: {retries: 5} + }); + + expect(axios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "http://localhost:9200/_bulk", + auth: {username: "user", password: "pass"}, + timeout: 1000, + params: {pipeline: "main"}, + headers: {"x-api-key": "key"} + }) + ); + expect(axiosRetry).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({retries: 5})); + }); + + it("should post each log as a bulk request when no buffer is configured", () => { + const {logger, client} = createLogger(); + + logger.info({event: "STARTED"}); + + expect(client).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + headers: {"Content-Type": "application/x-ndjson"} + }) + ); + + const [action, document] = getLines(client); + + expect(action.index).toMatchObject({ + _index: "logstash-test", + _type: "application", + _id: expect.any(String) + }); + expect(document).toMatchObject({ + event: "STARTED", + level_name: "info", + channel: "node", + datetime: expect.any(String) + }); + }); + + it("should resolve the index from a function", () => { + const {logger, client} = createLogger({application: () => "dynamic-index"}); + + logger.info("hello"); + + expect(getLines(client)[0].index._index).toEqual("dynamic-index"); + }); + + it("should wait for bufferMax logs before posting", () => { + const {logger, client} = createLogger({bufferMax: 2}); + + logger.info("first"); + + expect(client).not.toHaveBeenCalled(); + + logger.info("second"); + + expect(client).toHaveBeenCalledTimes(1); + expect(getLines(client)).toHaveLength(4); + }); + + it("should flush the buffer on shutdown", async () => { + const {logger, client} = createLogger({bufferMax: 10}); + + logger.info("first"); + + expect(client).not.toHaveBeenCalled(); + + await logger.shutdown(); + + expect(client).toHaveBeenCalledTimes(1); + }); + + it("should report a response error", async () => { + const {logger, client} = createLogger({bufferMax: 10}); + const error = vi.spyOn(console, "error").mockReturnValue(undefined); + + client.mockRejectedValue({response: {status: 500, data: {reason: "failed"}}}); + + logger.info("first"); + await logger.shutdown(); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('error posting to http://localhost:9200/_bulk: 500 - {"reason":"failed"}')); + + error.mockRestore(); + }); + + it("should report a network error", async () => { + const {logger, client} = createLogger({bufferMax: 10}); + const error = vi.spyOn(console, "error").mockReturnValue(undefined); + + client.mockRejectedValue(new Error("ECONNREFUSED")); + + logger.info("first"); + await logger.shutdown(); + + expect(error).toHaveBeenCalledWith("Ts.ED Logger.logstash-http Appender error: ECONNREFUSED"); + + error.mockRestore(); + }); +}); diff --git a/packages/logstash-http/vitest.config.mts b/packages/logstash-http/vitest.config.mts new file mode 100644 index 00000000..2be00ce9 --- /dev/null +++ b/packages/logstash-http/vitest.config.mts @@ -0,0 +1,19 @@ +// @ts-ignore +import {presets} from "@tsed/vitest/presets"; +import {defineConfig} from "vitest/config"; + +export default defineConfig({ + ...presets, + test: { + ...presets.test, + coverage: { + ...presets.test.coverage, + thresholds: { + statements: 0, + branches: 0, + functions: 0, + lines: 0 + } + } + } +}); diff --git a/packages/logstash-udp/package.json b/packages/logstash-udp/package.json index da5b8096..ff020986 100644 --- a/packages/logstash-udp/package.json +++ b/packages/logstash-udp/package.json @@ -38,7 +38,9 @@ }, "scripts": { "build": "yarn run build:ts", - "build:ts": "tsc --build tsconfig.esm.json" + "build:ts": "tsc --build tsconfig.esm.json", + "test": "vitest run", + "test:ci": "vitest run --coverage.thresholds.autoUpdate=true" }, "dependencies": { "tslib": "2.8.1" diff --git a/packages/logstash-udp/src/LogStashUdpAppender.spec.ts b/packages/logstash-udp/src/LogStashUdpAppender.spec.ts new file mode 100644 index 00000000..13655ef3 --- /dev/null +++ b/packages/logstash-udp/src/LogStashUdpAppender.spec.ts @@ -0,0 +1,112 @@ +import {AppendersRegistry, Logger} from "@tsed/logger"; +import * as dgram from "dgram"; +import {vi} from "vitest"; + +import {LogStashUdpAppender} from "./LogStashUdpAppender.js"; + +vi.mock("dgram"); + +function createLogger(options: Record = {}) { + const socket = { + send: vi.fn(), + close: vi.fn((cb: () => void) => cb()) + }; + + vi.mocked(dgram.createSocket).mockReturnValue(socket as never); + + const logger = new Logger("test"); + + logger.appenders.set("logstash", { + type: "logstash-udp", + options: { + host: "localhost", + port: 5000, + ...options + } + }); + + return {logger, socket}; +} + +function getPayload(socket: {send: ReturnType}) { + return JSON.parse(socket.send.mock.calls[0][0].toString()); +} + +describe("LogStashUdpAppender", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should be registered under the logstash-udp name", () => { + expect(AppendersRegistry.get("logstash-udp")!.provide).toBe(LogStashUdpAppender); + }); + + it("should send a message to the configured host", () => { + const {logger, socket} = createLogger(); + + logger.info("hello"); + + expect(dgram.createSocket).toHaveBeenCalledWith("udp4"); + expect(socket.send).toHaveBeenCalledWith(expect.any(Buffer), 0, expect.any(Number), 5000, "localhost", expect.any(Function)); + expect(getPayload(socket)).toMatchObject({ + "@version": 1, + "@timestamp": expect.any(String), + level: "INFO", + category: "test", + message: expect.stringContaining("hello") + }); + }); + + it("should merge the first object into the payload", () => { + const {logger, socket} = createLogger(); + + logger.error({event: "FAILED", code: 42}); + + const payload = getPayload(socket); + + expect(payload).toMatchObject({ + event: "FAILED", + code: 42, + level: "ERROR" + }); + expect(payload.message).toBeUndefined(); + }); + + it("should add the second plain object as fields", () => { + const {logger, socket} = createLogger(); + + logger.info("hello", {user: "john"}); + + expect(getPayload(socket).fields).toEqual({user: "john"}); + }); + + it("should use the given extraDataProvider", () => { + const {logger, socket} = createLogger({ + extraDataProvider: () => ({application: "my-app"}) + }); + + logger.info("hello"); + + expect(getPayload(socket).application).toEqual("my-app"); + }); + + it("should report a send error", () => { + const {logger, socket} = createLogger(); + const error = vi.spyOn(console, "error").mockReturnValue(undefined); + + logger.info("hello"); + socket.send.mock.calls[0][5](new Error("unreachable")); + + expect(error).toHaveBeenCalledWith(expect.stringContaining("Ts.ED Logger.logstash-udp - localhost:5000 Error:")); + + error.mockRestore(); + }); + + it("should close the socket on shutdown", async () => { + const {logger, socket} = createLogger(); + + await logger.shutdown(); + + expect(socket.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/logstash-udp/vitest.config.mts b/packages/logstash-udp/vitest.config.mts new file mode 100644 index 00000000..2be00ce9 --- /dev/null +++ b/packages/logstash-udp/vitest.config.mts @@ -0,0 +1,19 @@ +// @ts-ignore +import {presets} from "@tsed/vitest/presets"; +import {defineConfig} from "vitest/config"; + +export default defineConfig({ + ...presets, + test: { + ...presets.test, + coverage: { + ...presets.test.coverage, + thresholds: { + statements: 0, + branches: 0, + functions: 0, + lines: 0 + } + } + } +}); From 3597707dfd59d9995f52f940209a91fae11b7e31 Mon Sep 17 00:00:00 2001 From: Romain Lenzotti Date: Sat, 3 Oct 2026 18:12:21 +0200 Subject: [PATCH 2/2] docs: update getting started, custom layout and appender examples to v8 Co-Authored-By: Claude Opus 5.5 --- docs/appenders/file.md | 5 +++-- docs/appenders/index.md | 8 +++++--- docs/introduction/getting-started.md | 22 +++++++++++++++++++--- docs/introduction/migrate-to-v8.md | 2 +- docs/layouts/custom.md | 16 +++++----------- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/docs/appenders/file.md b/docs/appenders/file.md index f4b62a43..a741a3cd 100644 --- a/docs/appenders/file.md +++ b/docs/appenders/file.md @@ -72,13 +72,14 @@ When `all-the-logs.log` reaches 10Mb again, then all-the-logs.log.1.gz will be r ## Example with date rolling ```typescript -import {Logger} from "@tsed/logger-file"; +import {Logger} from "@tsed/logger"; import "@tsed/logger-file"; + export const logger = new Logger("Log Example"); logger.appenders.set("file", { type: "file", - filename: `${__dirname}/../logs/myfile.log`, + filename: `${import.meta.dirname}/../logs/myfile.log`, pattern: ".yyyy-MM-dd" }); ``` diff --git a/docs/appenders/index.md b/docs/appenders/index.md index 12d6de6a..a009351b 100644 --- a/docs/appenders/index.md +++ b/docs/appenders/index.md @@ -6,19 +6,21 @@ Appenders serialise log events to some form of output. They can write to files, ```typescript import {Logger} from "@tsed/logger"; +import "@tsed/logger-file"; +import "@tsed/logger-std"; const logger = new Logger("loggerName"); logger.appenders - .set({ + .set("stdout", { type: "stdout", levels: ["debug", "info", "trace"] }) - .set({ + .set("stderr", { type: "stderr", levels: ["error", "fatal", "warn"] }) - .set({ + .set("file", { type: "file", filename: "logfile.log" }); diff --git a/docs/introduction/getting-started.md b/docs/introduction/getting-started.md index c440da2b..3d164f76 100644 --- a/docs/introduction/getting-started.md +++ b/docs/introduction/getting-started.md @@ -7,11 +7,18 @@ otherTopics: true ## Installation ```bash -npm install -g typescript npm install @tsed/logger -npm install source-map-support ``` +`@tsed/logger` v8 is ESM only and ships the console appender and the colored layout. Other appenders and layouts are +installed and imported on demand: + +```bash +npm install @tsed/logger-std @tsed/logger-file @tsed/logger-pattern-layout +``` + +See the [migration guide](/introduction/migrate-to-v8) when you come from v7. + ## Quick start Minimalist version: @@ -35,6 +42,10 @@ Will be produce the following log output: ```typescript import {Logger} from "@tsed/logger"; +import "@tsed/logger/layouts/JsonLayout.js"; // registers the "json" layout +import "@tsed/logger-file"; // registers the "file" appender +import "@tsed/logger-pattern-layout"; // registers the "pattern" layout +import "@tsed/logger-std"; // registers the "stdout" and "stderr" appenders const logger = new Logger("loggerName"); logger.appenders @@ -52,7 +63,7 @@ logger.appenders }) .set("all-log-file", { type: "file", - filename: `${__dirname}/app.log`, + filename: `${import.meta.dirname}/app.log`, layout: { type: "json", separator: "," @@ -60,6 +71,11 @@ logger.appenders }); ``` +::: warning +An appender or a layout whose package is not imported is unknown to the logger: it prints a warning and falls back to the +console appender or the colored layout. +::: + ## Shutdown Shutdown return a Promise that will be resolved when logger has closed all appenders and finished writing log events. diff --git a/docs/introduction/migrate-to-v8.md b/docs/introduction/migrate-to-v8.md index 6cb58280..981e4c67 100644 --- a/docs/introduction/migrate-to-v8.md +++ b/docs/introduction/migrate-to-v8.md @@ -40,7 +40,7 @@ Then import and use it: import {StdoutAppender, StdErrAppender} from "@tsed/logger"; // v8 (new) -import {StdoutAppender, StdErrAppender} from "@tsed/logger-std"; +import {StdoutAppender, StderrAppender} from "@tsed/logger-std"; ``` #### Using PatternLayout diff --git a/docs/layouts/custom.md b/docs/layouts/custom.md index fee56281..c3af4e2c 100644 --- a/docs/layouts/custom.md +++ b/docs/layouts/custom.md @@ -7,11 +7,10 @@ You can add your own layouts with @@Layout()@@ before pushing a configure to you ```typescript [Decorator] // customLayout.ts import {BaseLayout, LogEvent, Layout} from "@tsed/logger"; -import {formatLogData} from "ts-log-debug/lib/utils/inpectUtils"; @Layout({name: "customJson"}) export class JsonLayout extends BaseLayout { - transform(loggingEvent: LogEvent, timezoneOffset?): string { + transform(loggingEvent: LogEvent, timezoneOffset?: number): string { const log = { startTime: loggingEvent.startTime, categoryName: loggingEvent.categoryName, @@ -20,8 +19,6 @@ export class JsonLayout extends BaseLayout { context: loggingEvent.context }; - log.data = log.data.map((data) => formatLogData([data])); - return JSON.stringify(log) + (this.config["separator"] || ""); } } @@ -29,11 +26,10 @@ export class JsonLayout extends BaseLayout { ```typescript [Functional API] // customLayout.ts -import {BaseLayout, LogEvent, Layout, layout} from "@tsed/logger"; -import {formatLogData} from "ts-log-debug/lib/utils/inpectUtils"; +import {BaseLayout, LogEvent, layout} from "@tsed/logger"; export class JsonLayout extends BaseLayout { - transform(loggingEvent: LogEvent, timezoneOffset?): string { + transform(loggingEvent: LogEvent, timezoneOffset?: number): string { const log = { startTime: loggingEvent.startTime, categoryName: loggingEvent.categoryName, @@ -42,8 +38,6 @@ export class JsonLayout extends BaseLayout { context: loggingEvent.context }; - log.data = log.data.map((data) => formatLogData([data])); - return JSON.stringify(log) + (this.config["separator"] || ""); } } @@ -57,7 +51,7 @@ This layout can be use like this: ```typescript import {Logger} from "@tsed/logger"; -import "./customLayout.ts"; +import "./customLayout.js"; const logger = new Logger("loggerName"); @@ -76,5 +70,5 @@ This example outputs the following: ```bash {"startTime":"2017-06-05T22:23:08.479Z","categoryName":"json-test","data":["this is just a test"],"level":"INFO","context":{}}, {"startTime":"2017-06-05T22:23:08.483Z","categoryName":"json-test","data":["of a custom appender"],"level":"ERROR","context":{}}, -{"startTime":"2017-06-05T22:23:08.483Z","categoryName":"json-test","data":["that outputs json"],"level""WARN","context":{}}, +{"startTime":"2017-06-05T22:23:08.483Z","categoryName":"json-test","data":["that outputs json"],"level":"WARN","context":{}}, ```