Skip to content
Open
58 changes: 56 additions & 2 deletions lib/DBSQLLogger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
import fs from 'fs';
import path from 'path';
import winston, { Logger } from 'winston';
import IDBSQLLogger, { LoggerOptions, LogLevel } from './contracts/IDBSQLLogger';

const DEFAULT_LOG_FILE_NAME = 'databricks-sql-nodejs.log';

Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
// An existing directory resolves to a default file inside it. Anything else is
// returned as the path winston will open (which drops a trailing separator).
function resolveLogFilePath(filepath: string): string {
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low — Treating a trailing / (or path.sep) as "directory, create it" goes beyond the issue's scope, which was an existing directory. It also changes behavior that callers could see: before, filepath: 'logs/' was handed straight to winston. Defaulting the file name to databricks-sql-nodejs.log is the PR author's own choice (as the description admits) and becomes part of the public contract through the new JSDoc. A maintainer should confirm this name and the trailing-separator semantics are wanted before merge, and a CHANGELOG entry may be needed because this is user-visible logging behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⛔ NEEDS HUMAN DECISION — the bots can't resolve this thread; a maintainer's input is required.

I agree with the point, but a maintainer has to make the call, so I haven't changed any code. resolveLogFilePath in lib/DBSQLLogger.ts does two things. First, it covers the reported bug: a filepath that names an existing directory now writes to a file inside it instead of failing with EISDIR. Second, it adds behavior the issue didn't ask for: a trailing / or path.sep is treated as a directory, and winston creates that directory if it's missing. Before this PR, 'logs/' went straight to winston, so the second part changes what callers see. A maintainer needs to decide: (1) whether databricks-sql-nodejs.log is the right default file name, now that the JSDoc publishes it; (2) whether to keep the trailing-separator handling or limit the fix to existing directories, which means deleting the endsWith('/') || endsWith(path.sep) branch and its tests; (3) whether to add a CHANGELOG entry. CHANGELOG.md has no Unreleased section and its entries go under maintainer-chosen version headings (latest is ## 3.0.0), so I can't add an entry without picking a release version. Once those are decided, either change is small and can go in this PR.

try {
if (fs.statSync(filepath).isDirectory()) {
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
return path.join(filepath, DEFAULT_LOG_FILE_NAME);
}
} catch {
// doesn't exist or isn't accessible: treat it as a file path
}
return path.join(path.dirname(filepath), path.basename(filepath));
}

// winston's File transport swallows errors opening its stream (EISDIR, EACCES,
// ENOTDIR, ...), after which the logger stalls for all transports once its
// buffer fills. Check up front that the file can be opened for appending.
// Note: this probe is intentionally not side-effect free. It creates missing
// parent directories and an empty log file (as winston itself would when the
// transport opens), so the check exercises the exact same open path.
// Limitations: this is a one-time, synchronous check (statSync/mkdirSync/
// openSync) run each time a DBSQLLogger is constructed with a `filepath`.
// Failures that start later (file/dir deleted, permissions changed, disk
// full, a Windows file lock) are not detected here and can still stall the
// logger. Follow-up (separate change): attach a runtime `error` handler to the
// File transport that removes it and falls back to console-only logging.
function checkLogFileWritable(filename: string): Error | undefined {
try {
fs.mkdirSync(path.dirname(filename), { recursive: true });
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
fs.closeSync(fs.openSync(filename, 'a'));
return undefined;
} catch (error) {
return error instanceof Error ? error : new Error(String(error));
}
}

export default class DBSQLLogger implements IDBSQLLogger {
logger: Logger;

Expand All @@ -21,8 +60,23 @@ export default class DBSQLLogger implements IDBSQLLogger {
transports: [this.transports.console],
});
if (filepath) {
this.transports.file = new winston.transports.File({ filename: filepath, handleExceptions: true, level });
this.logger.add(this.transports.file);
const filename = resolveLogFilePath(filepath);
const error = checkLogFileWritable(filename);
if (error) {
// Logged at `error` so the console transport's level filter can never
// drop it (e.g. when the logger is configured with `level: error`).
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
// Trade-off: consumers filtering on the winston `level` field (e.g.
// error-level alerting) WILL see this as an error; the "[warn] ...
// non-fatal" wording only helps human readers tell this config
// fallback apart from a driver failure.
this.logger.log({
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low — The fallback notice is sent at level: error with the text [warn] ... (non-fatal ...). The comment says the wording keeps log-based alerting from treating this as a driver failure. But structured consumers filter on the winston level field, not the message text, so error-level alerting will still fire on a config fallback. To surface it without breaking the level contract, you could write it straight to console.warn/process.emitWarning, or send it at warn and accept that level: error users won't see it. If you keep error on purpose, adjust the comment so it doesn't promise the message text changes alerting behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The smallest fix is the reviewer's own fallback option: keep error (the tests check it, and it keeps the notice visible at level: error) and correct the comment.

Pushed c1e837b.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keeping level: error is fine with me. The head I checked hasn't caught up with the change described in c1e837b, though. Lines 62–63 still say the message is "explicitly marked non-fatal so log-based alerting / readers don't mistake this config fallback for a driver failure". That's the claim my finding was about: alerting filters on the level field, so the wording won't stop it from firing. Something like "the text marks it non-fatal for human readers; level-based alerting will still see an error" would resolve this. If the commit didn't land on this branch, please push it again. Otherwise this needs a manual edit to the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⛔ NEEDS HUMAN DECISION — the bots can't resolve this thread; a maintainer's input is required.

The corrected comment is already on this branch and I didn't make any new edit. The working tree is clean and ai/issue-494 matches origin/ai/issue-494. In lib/DBSQLLogger.ts (around lines 60–65 of the DBSQLLogger constructor) the comment now reads: "Trade-off: consumers filtering on the winston level field (e.g. error-level alerting) WILL see this as an error; the "[warn] ... non-fatal" wording only helps human readers tell this config fallback apart from a driver failure." The old claim that the wording stops alerting from firing is gone. The review most likely ran against a head from before c1e837b landed. We agree on the outcome (keep level: error, fix the comment), so this thread has converged. A human only needs to confirm the current head shows the updated comment and resolve the thread.

level: LogLevel.error,
message: `[warn] Cannot write to log file ${filename}, logging to console only (non-fatal, driver continues normally): ${error.message}`,
});
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Low — The fallback notice goes out at error level with a [warn] prefix. The comment explains why: so that level: error can't filter it out. The cost is that anyone alerting on level === 'error' will now get a driver "error" for a config problem that doesn't affect the driver.

Two alternatives:

  • Log it at warn, accepting that level: error users won't see it.
  • Write it directly to process.stderr / console.warn, which skips winston's level filter without labelling it an error.

It's a judgement call, but this is a public connector, so a maintainer should decide it explicitly rather than leave it to the code comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⛔ NEEDS HUMAN DECISION — the bots can't resolve this thread; a maintainer's input is required.

I agree with the reviewer that this is a policy decision for a maintainer, so I made no code change. Right now (lib/DBSQLLogger.ts around line 70), when the log file can't be written, the driver logs one notice through winston at error level with a [warn] ... non-fatal prefix. The goal is that level: error users still see it. The cost is that anyone alerting on level === 'error' will get an alert for a config fallback that doesn't affect the driver. There are three options, and each is a small one-line change I can make once someone picks: (a) keep error as it is now, (b) log at warn and accept that level: error users won't see it, or (c) write it straight to process.stderr / console.warn, which skips winston's level filter and transports entirely, so it won't reach custom transports or structured log pipelines. This is a public connector, so a maintainer needs to choose which behaviour users get.

this.transports.file = new winston.transports.File({ filename, handleExceptions: true, level });
this.logger.add(this.transports.file);
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions lib/contracts/IDBSQLLogger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export interface LoggerOptions {
/**
* Optional: a log file to write to, in addition to the console. If it names an
* existing directory, logs go to `databricks-sql-nodejs.log` inside it. If the
* file can't be opened for writing, the logger reports it on the console (at
* `error` level, so it is shown regardless of `level`) and logs to the console only.
*/
filepath?: string;
level?: LogLevel;
}
Expand Down
146 changes: 146 additions & 0 deletions tests/unit/DBSQLLogger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,27 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import fs from 'fs';
import os from 'os';
import path from 'path';
import { expect } from 'chai';
import sinon from 'sinon';
import winston from 'winston';
import DBSQLLogger from '../../lib/DBSQLLogger';
import { LogLevel } from '../../lib/contracts/IDBSQLLogger';

async function waitForFileContent(file: string, text: string, timeoutMs = 2000): Promise<string> {
const deadline = Date.now() + timeoutMs;
const read = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
while (!read().includes(text) && Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
return read();
}

describe('DBSQLLogger level subscription', () => {
it('getLevel reflects the configured level', () => {
const logger = new DBSQLLogger({ level: LogLevel.warn });
Expand Down Expand Up @@ -60,3 +77,132 @@ describe('DBSQLLogger level subscription', () => {
expect(logger.getLevel()).to.equal(LogLevel.debug);
});
});

describe('DBSQLLogger file transport path resolution', () => {
let tmpDir: string;
let logger: DBSQLLogger | undefined;
let fileSettled: Promise<void> | undefined;

// The File transport opens its stream asynchronously; teardown waits for that
// before closing the logger and deleting the directory.
function createLogger(filepath: string, level?: LogLevel): DBSQLLogger {
const instance = new DBSQLLogger({ filepath, level });
const file = instance.transports.file;
fileSettled = file
? new Promise<void>((resolve) => {
file.once('open', () => resolve());
file.once('error', () => resolve());
})
: undefined;
return instance;
}

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dbsql-logger-'));
});

afterEach(async () => {
if (fileSettled) {
await fileSettled;
}
logger?.logger.close();
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
logger = undefined;
fileSettled = undefined;
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('writes to a default file inside the directory when filepath is an existing directory', () => {
logger = createLogger(tmpDir);

expect(logger.transports.file).to.not.be.undefined;
expect(logger.transports.file?.dirname).to.equal(tmpDir);
expect(logger.transports.file?.filename).to.equal('databricks-sql-nodejs.log');
});

it('writes log lines to the default file inside the directory', async () => {
logger = createLogger(tmpDir);
logger.transports.console.silent = true;

await logger.log(LogLevel.info, 'hello from DBSQLLogger test');

const content = await waitForFileContent(path.join(tmpDir, 'databricks-sql-nodejs.log'), 'hello from');
expect(content).to.contain('hello from DBSQLLogger test');
});

it('resolves an existing directory with a trailing separator to the default file inside it', () => {
logger = createLogger(`${tmpDir}${path.sep}`);

expect(logger.transports.file?.dirname).to.equal(tmpDir);
expect(logger.transports.file?.filename).to.equal('databricks-sql-nodejs.log');
});

it('keeps writing to an existing file when filepath has a trailing separator', () => {
const filepath = path.join(tmpDir, 'logs');
fs.writeFileSync(filepath, '');
logger = createLogger(`${filepath}${path.sep}`);

expect(logger.transports.file?.dirname).to.equal(tmpDir);
expect(logger.transports.file?.filename).to.equal('logs');
});

for (const level of [LogLevel.info, LogLevel.error]) {
it(`falls back to console only, with a notice, when the log file cannot be opened (level: ${level})`, async () => {
const notADir = path.join(tmpDir, 'not-a-dir');
fs.writeFileSync(notADir, '');
const logged: Array<{ level: string; message: string }> = [];
const consoleLog = sinon.stub(winston.transports.Console.prototype, 'log').callsFake((...args: unknown[]) => {
logged.push(args[0] as { level: string; message: string });
(args[1] as () => void)();
});
try {
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
logger = createLogger(path.join(notADir, 'driver.log'), level);
// Delivery to the Console transport goes through winston's stream piping,
// so poll rather than assuming it completes within a single tick.
const deadline = Date.now() + 2000;
while (logged.length === 0 && Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}

expect(logger.transports.file).to.be.undefined;
expect(logged).to.have.length(1);
expect(logged[0].level).to.equal(LogLevel.error);
expect(logged[0].message).to.contain('logging to console only');
} finally {
consoleLog.restore();
}
});
}

it('uses a non-existent filepath unchanged', () => {
const filepath = path.join(tmpDir, 'custom.log');
logger = createLogger(filepath);

expect(logger.transports.file).to.not.be.undefined;
expect(logger.transports.file?.dirname).to.equal(tmpDir);
expect(logger.transports.file?.filename).to.equal('custom.log');
});

it('creates missing parent directories and still attaches the file transport', () => {
const nestedDir = path.join(tmpDir, 'a', 'b');
const filepath = path.join(nestedDir, 'driver.log');
logger = createLogger(filepath);

expect(logger.transports.file).to.not.be.undefined;
expect(logger.transports.file?.dirname).to.equal(nestedDir);
expect(logger.transports.file?.filename).to.equal('driver.log');
expect(fs.statSync(nestedDir).isDirectory()).to.be.true;
expect(fs.existsSync(filepath)).to.be.true;
});

it('uses an existing file path unchanged', () => {
const filepath = path.join(tmpDir, 'existing.log');
fs.writeFileSync(filepath, '');
logger = createLogger(filepath);

expect(logger.transports.file?.dirname).to.equal(tmpDir);
expect(logger.transports.file?.filename).to.equal('existing.log');
});
});
Loading