Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
- Fixed a race condition in `SourceTokenScraper` that could cause concurrent function deployments to hang when refreshing an expired source token (#6989).
- Fixed an issue where a Function Kit and a functions codebase sharing the same source directory failed `firebase.json` validation. (#11253)
- Fixed an issue where Cloud Functions creation did not retry on HTTP 403 service account propagation errors. (#11251)
- Fixed an issue where logs from downloadable emulators (such as Cloud Firestore and Realtime Database) were not streamed to the Emulator Suite UI.
29 changes: 29 additions & 0 deletions src/emulator/downloadableEmulators.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,32 @@ describe("downloadDetails", () => {
expect(downloadableEmulators.emulatorVersionOverride(Emulators.PUBSUB)).to.equal(fakeVersion);
});
});

describe("parseLogLevel", () => {
it("should parse SEVERE and ERROR logs as ERROR", () => {
expect(downloadableEmulators.parseLogLevel("SEVERE: Unhandled exception")).to.equal("ERROR");
expect(downloadableEmulators.parseLogLevel("ERROR: Rules evaluation failed")).to.equal("ERROR");
expect(downloadableEmulators.parseLogLevel("[error] something failed")).to.equal("ERROR");
});

it("should parse WARNING and WARN logs as WARN", () => {
expect(downloadableEmulators.parseLogLevel("WARNING: Permission denied")).to.equal("WARN");
expect(downloadableEmulators.parseLogLevel("WARN: Deprecated syntax")).to.equal("WARN");
expect(downloadableEmulators.parseLogLevel("[warning] check rules")).to.equal("WARN");
});

it("should parse INFO logs as INFO", () => {
expect(downloadableEmulators.parseLogLevel("INFO: Cloud Firestore Emulator running")).to.equal(
"INFO",
);
expect(downloadableEmulators.parseLogLevel("[info] Listening on port 8080")).to.equal("INFO");
});

it("should default to DEBUG when no higher severity keyword matches", () => {
expect(downloadableEmulators.parseLogLevel("FINE: Detailed trace message")).to.equal("DEBUG");
expect(downloadableEmulators.parseLogLevel("API endpoint: http://localhost:8080")).to.equal(
"DEBUG",
);
expect(downloadableEmulators.parseLogLevel("Some informational text")).to.equal("DEBUG");
});
});
24 changes: 18 additions & 6 deletions src/emulator/downloadableEmulators.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const lsofi = require("lsofi");

Check warning on line 1 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Require statement not part of import statement

Check warning on line 1 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
import {
Emulators,
DownloadableEmulators,
Expand Down Expand Up @@ -116,7 +116,7 @@
version: emulatorUiDetails.version,
downloadPath: path.join(CACHE_DIR, emulatorUiDetails.downloadPathRelativeToCacheDir),
unzipDir: path.join(CACHE_DIR, `ui-v${emulatorUiDetails.version}`),
binaryPath: path.join(CACHE_DIR, emulatorUiDetails.binaryPathRelativeToCacheDir!),

Check warning on line 119 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
opts: {
...emulatorUiDetails,
cacheDir: CACHE_DIR,
Expand All @@ -136,7 +136,7 @@
unzipDir: path.join(CACHE_DIR, `pubsub-emulator-${EMULATOR_UPDATE_DETAILS.pubsub.version}`),
binaryPath: path.join(
CACHE_DIR,
EMULATOR_UPDATE_DETAILS.pubsub.binaryPathRelativeToCacheDir!,

Check warning on line 139 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
),
opts: {
...EMULATOR_UPDATE_DETAILS.pubsub,
Expand Down Expand Up @@ -165,7 +165,7 @@

if (overrideVersion && overrideVersion !== details.version) {
const oldVersion = details.version;
const replaceVersion = (s: string) => s.split(oldVersion).join(overrideVersion);

Check warning on line 168 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function

details.version = overrideVersion;
details.downloadPath = replaceVersion(details.downloadPath);
Expand Down Expand Up @@ -273,7 +273,7 @@
shell: false,
},
pubsub: {
binary: `${getExecPath(Emulators.PUBSUB)!}`,

Check warning on line 276 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
args: [],
optionalArgs: ["port", "host"],
joinArgs: true,
Expand Down Expand Up @@ -309,7 +309,7 @@
}

/**
* @param name

Check warning on line 312 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing JSDoc @PARAM "name" description
*/
export function getLogFileName(name: string): string {
return `${name}-debug.log`;
Expand All @@ -322,7 +322,7 @@
*/
export function _getCommand(
emulator: DownloadableEmulators,
args: { [s: string]: any },

Check warning on line 325 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
): DownloadableEmulatorCommand {
const baseCmd = Commands[emulator];
const defaultPort = Constants.getDefaultPort(emulator);
Expand All @@ -335,7 +335,7 @@
if (
baseCmd.binary === "java" &&
utils.isRunningInWSL() &&
(!args.host || !args.host.includes(":"))

Check warning on line 338 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe call of an `any` typed value

Check warning on line 338 in src/emulator/downloadableEmulators.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .includes on an `any` value
) {
// HACK(https://github.com/firebase/firebase-tools-ui/issues/332): Force
// Java to use IPv4 sockets in WSL (unless IPv6 address explicitly used).
Expand Down Expand Up @@ -433,6 +433,16 @@
return false;
}

/**
* Parses the severity level from a downloadable emulator output line.
*/
export function parseLogLevel(text: string): "INFO" | "WARN" | "ERROR" | "DEBUG" {
if (/\b(SEVERE|ERROR)\b/i.test(text)) return "ERROR";
if (/\b(WARNING|WARN)\b/i.test(text)) return "WARN";
if (/\bINFO\b/i.test(text)) return "INFO";
return "DEBUG";
}

async function _runBinary(
emulator: DownloadableEmulatorDetails,
command: DownloadableEmulatorCommand,
Expand Down Expand Up @@ -492,23 +502,25 @@
`${description} logging to ${clc.bold(getLogFileName(emulator.name))}`,
);

emulator.instance.stdout?.on("data", (data) => {
logger.log("DEBUG", data.toString());
emulator.instance.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
logger.log(parseLogLevel(text), text);
emulator.stdout.write(data);
});
emulator.instance.stderr?.on("data", (data) => {
logger.log("DEBUG", data.toString());
emulator.instance.stderr?.on("data", (data: Buffer) => {
const text = data.toString();
logger.log(parseLogLevel(text), text);
emulator.stdout.write(data);
Comment on lines +505 to 513

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Parsing the log level and logging it directly via logger.log(parseLogLevel(text), text) will cause all INFO, WARN, and ERROR logs from the emulator child processes to be printed directly to the user's terminal/console. This will result in massive console spam during normal emulator operation.

To prevent this console spam while still streaming the logs to the Emulator UI, we can log them at the "INFO" level but include a metadata flag (e.g., emulatorLog: true) and the parsed level in metadata.level. We can then filter out these logs in the Console transport unless --debug is enabled.

    emulator.instance.stdout?.on("data", (data: Buffer) => {
      const text = data.toString();
      logger.log("INFO", text, {
        metadata: {
          emulatorLog: true,
          level: parseLogLevel(text),
        },
      });
      emulator.stdout.write(data);
    });
    emulator.instance.stderr?.on("data", (data: Buffer) => {
      const text = data.toString();
      logger.log("INFO", text, {
        metadata: {
          emulatorLog: true,
          level: parseLogLevel(text),
        },
      });
      emulator.stdout.write(data);
    });


if (data.toString().includes("java.lang.UnsupportedClassVersionError")) {
if (text.includes("java.lang.UnsupportedClassVersionError")) {
logger.logLabeled(
"WARN",
emulator.name,
"Unsupported java version, make sure java --version reports 1.8 or higher.",
);
}

if (data.toString().includes("address already in use")) {
if (text.includes("address already in use")) {
const message = `${description} has exited because its configured port ${command.port} is already in use. Are you running another copy of the emulator suite?`;
logger.logLabeled("ERROR", emulator.name, message);
}
Expand Down
103 changes: 103 additions & 0 deletions src/emulator/loggingEmulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,107 @@ describe("LoggingEmulator", () => {
const receivedMessage = await messagePromise;
expect(receivedMessage.message).to.contain("Hello world from unit test!");
});

it("should discard debug messages when DEBUG env is not set", async () => {
const originalDebug = process.env.DEBUG;
const originalLevel = logger.level;
delete process.env.DEBUG;
logger.level = "info";

try {
emulator = new LoggingEmulator({ port, host: "127.0.0.1" });
await emulator.start();

const ws = new WebSocket(`ws://127.0.0.1:${port}`);
client = ws;

const received: LogEntry[] = [];
const infoPromise = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for info message"));
}, 5000);

ws.on("message", (data) => {
try {
const entry = JSON.parse(data.toString()) as LogEntry;
received.push(entry);
if (entry.message.includes("Info message after debug")) {
clearTimeout(timeout);
resolve();
}
} catch (e) {
clearTimeout(timeout);
reject(e);
}
});
});

await new Promise<void>((resolve, reject) => {
ws.on("open", () => resolve());
ws.on("error", (err) => reject(err));
});

logger.debug("Should be ignored");
logger.info("Info message after debug");

await infoPromise;
expect(received.some((m) => m.message.includes("Should be ignored"))).to.be.false;
expect(received.some((m) => m.message.includes("Info message after debug"))).to.be.true;
} finally {
if (originalDebug !== undefined) {
process.env.DEBUG = originalDebug;
} else {
delete process.env.DEBUG;
}
logger.level = originalLevel;
}
});

it("should broadcast debug messages when DEBUG env is set", async () => {
const originalDebug = process.env.DEBUG;
const originalLevel = logger.level;
process.env.DEBUG = "true";
logger.level = "debug";

try {
emulator = new LoggingEmulator({ port, host: "127.0.0.1" });
await emulator.start();

const ws = new WebSocket(`ws://127.0.0.1:${port}`);
client = ws;

const messagePromise = new Promise<LogEntry>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout waiting for debug message"));
}, 5000);

ws.on("message", (data) => {
clearTimeout(timeout);
try {
resolve(JSON.parse(data.toString()) as LogEntry);
} catch (e) {
reject(e);
}
});
});

await new Promise<void>((resolve, reject) => {
ws.on("open", () => resolve());
ws.on("error", (err) => reject(err));
});

logger.debug("Debug message from unit test!");

const receivedMessage = await messagePromise;
expect(receivedMessage.level).to.equal("debug");
expect(receivedMessage.message).to.contain("Debug message from unit test!");
} finally {
if (originalDebug !== undefined) {
process.env.DEBUG = originalDebug;
} else {
delete process.env.DEBUG;
}
logger.level = originalLevel;
}
});
});
6 changes: 4 additions & 2 deletions src/emulator/loggingEmulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export class LoggingEmulator implements EmulatorInstance {
constructor(private args: LoggingEmulatorArgs) {}

start(): Promise<void> {
this.transport = new WebSocketTransport();
this.transport = new WebSocketTransport({
level: process.env.DEBUG ? "debug" : "info",
});
this.transport.start(this.getInfo());
logger.add(this.transport);
return Promise.resolve();
Expand Down Expand Up @@ -78,7 +80,7 @@ class WebSocketTransport extends TransportStream {
connections = new Set<WebSocket>();
history: LogEntry[] = [];

constructor(options = {}) {
constructor(options: TransportStream.TransportStreamOptions = {}) {
super(options);
this.setMaxListeners(30);
}
Expand Down
37 changes: 36 additions & 1 deletion src/logger.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import * as path from "path";

import * as Transport from "winston-transport";

import { logger, findAvailableLogFile, resolveLogTarget, useFileLogger } from "./logger";
import {
logger,
findAvailableLogFile,
resolveLogTarget,
useFileLogger,
useConsoleLoggers,
} from "./logger";

interface FileTransport extends Transport {
dirname?: string;
Expand All @@ -27,10 +33,13 @@ function getLoggerTransports(logObj: unknown): FileTransport[] {

describe("logger", () => {
const originalEnv = process.env.FIREBASE_DEBUG_PATH;
const originalDebug = process.env.DEBUG;
let originalLevel: string;
let testTmpDir: string;

beforeEach(() => {
delete process.env.FIREBASE_DEBUG_PATH;
originalLevel = logger.level;
testTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "firebase-debug-test-"));
});

Expand All @@ -40,6 +49,12 @@ describe("logger", () => {
} else {
delete process.env.FIREBASE_DEBUG_PATH;
}
if (originalDebug !== undefined) {
process.env.DEBUG = originalDebug;
} else {
delete process.env.DEBUG;
}
logger.level = originalLevel;
sinon.restore();
if (fs.existsSync(testTmpDir)) {
fs.rmSync(testTmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
Expand Down Expand Up @@ -311,4 +326,24 @@ describe("logger", () => {
}
});
});

describe("useConsoleLoggers", () => {
it("should set root logger level to debug when DEBUG env var is set", () => {
process.env.DEBUG = "true";
const beforeTransports = [...getLoggerTransports(logger)];

useConsoleLoggers();

expect(logger.level).to.equal("debug");

const afterTransports = getLoggerTransports(logger);
const addedTransports = afterTransports.filter((t) => !beforeTransports.includes(t));
expect(addedTransports).to.have.lengthOf(1);
expect(addedTransports[0].level).to.equal("debug");

for (const t of addedTransports) {
logger.remove(t);
}
});
});
});
2 changes: 2 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface Logger {
remove(transport: Transport): Logger;

silent: boolean;
level: string;
}

function expandErrors(logger: winston.Logger): winston.Logger {
Expand Down Expand Up @@ -297,6 +298,7 @@ export function useFileLogger(logFile?: string): string {
*/
export function useConsoleLoggers(): void {
if (process.env.DEBUG) {
logger.level = "debug";
logger.add(
new winston.transports.Console({
level: "debug",
Expand Down
Loading