Skip to content

Commit f943785

Browse files
committed
add logging hooks
1 parent 6607987 commit f943785

13 files changed

Lines changed: 760 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1010

1111
- Generated OpenAPI documents for the supported `gateway_api_20` and
1212
`wiadomosci.librus.pl/api` surfaces.
13+
- Secret-safe SDK logging hooks for Portal login and Synergia request lifecycle
14+
events, plus CLI `--verbose` and `LIBRUS_LOG_LEVEL` stderr diagnostics.
1315

1416
## [0.7.1] - 2026-05-20
1517

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ these variables:
8383
| `LIBRUS_GATEWAY_PASSWORD` | `gateway_api_20` | Password for the Gateway API 2.0 login. |
8484
| `LIBRUS_CHILD` | No | Optional default child id or login for `api_v3` CLI commands. |
8585
| `LIBRUS_TIMEOUT_MS` | No | Positive integer request timeout in milliseconds. |
86+
| `LIBRUS_LOG_LEVEL` | No | Optional CLI SDK log level: `debug`, `info`, `warn`, or `error`. |
8687

8788
If no timeout is configured, portal and child-scoped SDK requests default to
8889
`30000` milliseconds. Invalid timeout values fail fast with
@@ -117,6 +118,7 @@ import {
117118
generateWiadomosciOpenApiDocument,
118119
LibrusSdkError,
119120
type LibrusApiBackend,
121+
type Logger,
120122
} from "librus-sdk";
121123
```
122124

@@ -127,6 +129,7 @@ import {
127129
| `GatewayApi20AuthClient` | Lower-level Gateway API 2.0 login/password auth client for `synergia.librus.pl/gateway/api/2.0` cookie-backed requests. |
128130
| `PortalClient` | Lower-level portal session client for `portal.librus.pl` login, `/Me`, and `/SynergiaAccounts`. |
129131
| `SynergiaApiClient` | Child-scoped GET client for the supported `https://api.librus.pl/3.0` surface when you already have a bearer token. |
132+
| `Logger` | Minimal structured logging interface accepted by SDK clients and sessions. |
130133
| `generateOpenApiDocument` | Generates the shipped OpenAPI document for the supported `api_v3` child-scoped GET subset. |
131134
| `generateGatewayApi20OpenApiDocument` | Generates the shipped OpenAPI document for the supported `gateway_api_20` GET subset. |
132135
| `generateWiadomosciOpenApiDocument` | Generates the shipped OpenAPI document for the supported `wiadomosci.librus.pl/api` inbox subset. |
@@ -143,6 +146,26 @@ const grades = await client.getGrades();
143146
console.log(grades);
144147
```
145148

149+
Custom logger:
150+
151+
```ts
152+
import { LibrusSession, type Logger } from "librus-sdk";
153+
import pino from "pino";
154+
155+
const pinoLogger = pino();
156+
const logger: Logger = {
157+
log: (level, event, fields) => pinoLogger[level]({ event, ...fields }),
158+
};
159+
160+
const session = LibrusSession.fromEnv(process.env, { logger });
161+
const client = await session.forChild("child-login");
162+
await client.getGrades();
163+
```
164+
165+
SDK logs are structured and secret-safe. They include request URLs, status,
166+
durations, auth mode, and stable error codes, but never include passwords,
167+
bearer tokens, cookie values, request bodies, or response bodies.
168+
146169
Gateway API 2.0 flow:
147170

148171
```bash
@@ -171,9 +194,9 @@ with `UNSUPPORTED_BACKEND` in `gateway_api_20`.
171194

172195
Current high-level methods on `LibrusSession`:
173196

174-
- `LibrusSession.fromEnv(env?)`
197+
- `LibrusSession.fromEnv(env?, options?)`
175198
- `LibrusSession.fromGatewayCredentials(credentials, options?)`
176-
- `new LibrusSession({ apiBackend?, credentials, portalClient?, portalClientOptions?, gatewayApi20AuthClient?, gatewayApi20AuthClientOptions?, bffClientOptions?, synergiaClientOptions?, wiadomosciClientOptions?, requestTimeoutMs? })`
199+
- `new LibrusSession({ apiBackend?, credentials, portalClient?, portalClientOptions?, gatewayApi20AuthClient?, gatewayApi20AuthClientOptions?, bffClientOptions?, synergiaClientOptions?, wiadomosciClientOptions?, requestTimeoutMs?, logger? })`
177200
- `login()`
178201
- `getApiBackend()`
179202
- `getPortalMe()`
@@ -311,6 +334,7 @@ Root CLI commands:
311334

312335
- `librus-sdk --help`
313336
- `librus-sdk --version`
337+
- `librus-sdk --verbose <command>`
314338

315339
Every leaf command supports `--format <text|json>`. In `api_v3`, child-scoped
316340
commands require a child selector: pass `--child <id-or-login>` or set
@@ -320,6 +344,10 @@ explicit `--child` fails with `UNSUPPORTED_BACKEND`. Portal-only commands such
320344
as `children list`, `messages bff-list`, and `messages wiadomosci-list` are not
321345
available in `gateway_api_20`.
322346

347+
`--verbose` enables debug-level SDK diagnostics on stderr. `LIBRUS_LOG_LEVEL`
348+
can enable diagnostics without a flag and filters by minimum level. Successful
349+
command payloads still go to stdout, so `--format json` remains machine-readable.
350+
323351
| Family | Subcommands | Extra selectors and flags |
324352
| ---------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
325353
| `children` | `list` | Portal-only; no child selector. |

src/cli/commands/common.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { InvalidArgumentError, type Command } from "commander";
44
import {
55
LibrusSession,
66
LibrusSdkError,
7+
type Logger,
78
type ChildAccount,
89
type ChildAccountSummary,
910
type LibrusApiBackend,
@@ -33,7 +34,7 @@ export interface CliOutput {
3334
export interface CliContext {
3435
stdout: CliOutput;
3536
stderr: CliOutput;
36-
createSession: () => LibrusSession;
37+
createSession: (logger?: Logger) => LibrusSession;
3738
outputWidth?: number;
3839
writeFile?: (path: string, data: Uint8Array) => void;
3940
}

src/cli/logger.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
LibrusConfigurationError,
3+
type Logger,
4+
type LogLevel,
5+
} from "../sdk/index.js";
6+
import type { CliOutput } from "./commands/common.js";
7+
8+
const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
9+
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
10+
debug: 10,
11+
info: 20,
12+
warn: 30,
13+
error: 40,
14+
};
15+
16+
export function createCliLogger(
17+
output: CliOutput,
18+
minimumLevel: LogLevel,
19+
): Logger {
20+
return {
21+
log(level, event, fields = {}) {
22+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[minimumLevel]) {
23+
return;
24+
}
25+
26+
output.write(
27+
`${new Date().toISOString()} ${level.toUpperCase()} ${event}${formatFields(fields)}\n`,
28+
);
29+
},
30+
};
31+
}
32+
33+
export function resolveCliLogLevel(
34+
argv: string[],
35+
env: NodeJS.ProcessEnv = process.env,
36+
): LogLevel | undefined {
37+
if (argv.includes("--verbose")) {
38+
return "debug";
39+
}
40+
41+
const rawLogLevel = env.LIBRUS_LOG_LEVEL;
42+
43+
if (rawLogLevel === undefined) {
44+
return undefined;
45+
}
46+
47+
const logLevel = rawLogLevel.trim().toLowerCase();
48+
49+
if (isLogLevel(logLevel)) {
50+
return logLevel;
51+
}
52+
53+
throw new LibrusConfigurationError(
54+
'Invalid LIBRUS_LOG_LEVEL. Expected "debug", "info", "warn", or "error".',
55+
);
56+
}
57+
58+
function isLogLevel(value: string): value is LogLevel {
59+
return LOG_LEVELS.includes(value as LogLevel);
60+
}
61+
62+
function formatFields(fields: Record<string, unknown>): string {
63+
const entries = Object.entries(fields).filter(
64+
([, value]) => value !== undefined,
65+
);
66+
67+
if (entries.length === 0) {
68+
return "";
69+
}
70+
71+
return ` ${entries
72+
.map(([key, value]) => `${key}=${formatFieldValue(value)}`)
73+
.join(" ")}`;
74+
}
75+
76+
function formatFieldValue(value: unknown): string {
77+
if (
78+
value === null ||
79+
typeof value === "number" ||
80+
typeof value === "boolean"
81+
) {
82+
return String(value);
83+
}
84+
85+
if (typeof value === "string") {
86+
return JSON.stringify(value);
87+
}
88+
89+
return JSON.stringify(value);
90+
}

src/cli/main.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { createMeCommand } from "./commands/me.js";
2626
import { createNotesCommand } from "./commands/notes.js";
2727
import { createNotificationsCommand } from "./commands/notifications.js";
2828
import { createTimetableCommand } from "./commands/timetable.js";
29+
import { createCliLogger, resolveCliLogLevel } from "./logger.js";
2930

3031
const packageJson = JSON.parse(
3132
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
@@ -39,7 +40,10 @@ export function createDefaultCliContext(): CliContext {
3940
stderr: {
4041
write: (chunk) => process.stderr.write(chunk),
4142
},
42-
createSession: () => LibrusSession.fromEnv(),
43+
createSession: (logger) =>
44+
logger === undefined
45+
? LibrusSession.fromEnv()
46+
: LibrusSession.fromEnv(process.env, { logger }),
4347
outputWidth: process.stdout.columns ?? 80,
4448
writeFile: (path, data) => writeFileSync(path, data),
4549
};
@@ -50,7 +54,8 @@ export function createProgram(context: CliContext): Command {
5054
new Command()
5155
.name("librus-sdk")
5256
.description("CLI for the Librus family portal flow")
53-
.version(packageJson.version),
57+
.version(packageJson.version)
58+
.option("--verbose", "Write diagnostic logs to stderr"),
5459
context,
5560
);
5661

@@ -76,19 +81,35 @@ export async function runCli(
7681
argv = process.argv,
7782
context = createDefaultCliContext(),
7883
): Promise<number> {
79-
const program = createProgram(context);
8084
const userArgs = argv.slice(2);
85+
const helpProgram = createProgram(context);
8186

8287
if (
8388
userArgs.length === 0 ||
8489
(userArgs.length === 1 &&
8590
(userArgs[0] === "--help" || userArgs[0] === "-h"))
8691
) {
87-
context.stdout.write(program.helpInformation());
92+
context.stdout.write(helpProgram.helpInformation());
8893
return 0;
8994
}
9095

9196
try {
97+
const logLevel = isInformationalCliInvocation(userArgs)
98+
? undefined
99+
: resolveCliLogLevel(userArgs);
100+
const logger =
101+
logLevel === undefined
102+
? undefined
103+
: createCliLogger(context.stderr, logLevel);
104+
const runtimeContext =
105+
logger === undefined
106+
? context
107+
: {
108+
...context,
109+
createSession: () => context.createSession(logger),
110+
};
111+
const program = createProgram(runtimeContext);
112+
92113
await program.parseAsync(argv);
93114
return 0;
94115
} catch (error) {
@@ -119,6 +140,13 @@ export async function runCli(
119140
}
120141
}
121142

143+
function isInformationalCliInvocation(argv: string[]): boolean {
144+
return argv.some(
145+
(arg) =>
146+
arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V",
147+
);
148+
}
149+
122150
export function isCliEntryPoint(
123151
argv = process.argv,
124152
moduleUrl = import.meta.url,

src/sdk/LibrusSession.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
validateOptionalRequestTimeoutMs,
3131
} from "./requestTimeout.js";
3232
import { emitDeprecationWarningOnce } from "./deprecationWarnings.js";
33+
import type { Logger } from "./logger.js";
3334

3435
type PortalCredentialEnvName =
3536
| "LIBRUS_EMAIL"
@@ -103,6 +104,7 @@ export interface LibrusSessionOptions {
103104
"ensurePortalLogin" | "portalClient"
104105
>;
105106
requestTimeoutMs?: number;
107+
logger?: Logger;
106108
}
107109

108110
export class LibrusSession {
@@ -154,9 +156,13 @@ export class LibrusSession {
154156
options.portalClientOptions,
155157
requestTimeoutMs,
156158
);
157-
const synergiaClientOptions = applyRequestTimeout(
158-
options.synergiaClientOptions,
159-
requestTimeoutMs,
159+
const loggedPortalClientOptions = applyLogger(
160+
portalClientOptions,
161+
options.logger,
162+
);
163+
const synergiaClientOptions = applyLogger(
164+
applyRequestTimeout(options.synergiaClientOptions, requestTimeoutMs),
165+
options.logger,
160166
);
161167
const bffClientOptions = applyRequestTimeout(
162168
options.bffClientOptions,
@@ -175,7 +181,7 @@ export class LibrusSession {
175181
if (this.apiBackend === "api_v3") {
176182
this.portalCredentials = normalizePortalCredentials(options.credentials);
177183
this.portalClient =
178-
options.portalClient ?? new PortalClient(portalClientOptions);
184+
options.portalClient ?? new PortalClient(loggedPortalClientOptions);
179185
} else {
180186
this.gatewayCredentials = normalizeGatewayCredentials(
181187
options.credentials,
@@ -191,22 +197,34 @@ export class LibrusSession {
191197
this.wiadomosciClientOptions = wiadomosciClientOptions;
192198
}
193199

194-
static fromEnv(env: NodeJS.ProcessEnv = process.env): LibrusSession {
200+
static fromEnv(
201+
env: NodeJS.ProcessEnv = process.env,
202+
options: Omit<
203+
LibrusSessionOptions,
204+
"apiBackend" | "authMode" | "credentials"
205+
> = {},
206+
): LibrusSession {
195207
const apiBackend = resolveApiBackend(env);
196208
const requestTimeoutMs = parseRequestTimeoutMsFromEnv(
197209
env.LIBRUS_TIMEOUT_MS,
198210
);
199-
const options = requestTimeoutMs === undefined ? {} : { requestTimeoutMs };
211+
const sessionOptions =
212+
requestTimeoutMs === undefined
213+
? options
214+
: {
215+
...options,
216+
requestTimeoutMs: options.requestTimeoutMs ?? requestTimeoutMs,
217+
};
200218

201219
if (apiBackend === "gateway_api_20") {
202220
return LibrusSession.fromGatewayCredentials(
203221
resolveGatewayCredentialsFromEnv(env),
204-
options,
222+
sessionOptions,
205223
);
206224
}
207225

208226
return new LibrusSession({
209-
...options,
227+
...sessionOptions,
210228
apiBackend: "api_v3",
211229
credentials: resolvePortalCredentialsFromEnv(env),
212230
});
@@ -738,3 +756,14 @@ function applyRequestTimeout<T extends { requestTimeoutMs?: number }>(
738756
? { ...options, requestTimeoutMs }
739757
: ({ requestTimeoutMs } as T);
740758
}
759+
760+
function applyLogger<T extends { logger?: Logger }>(
761+
options: T | undefined,
762+
logger: Logger | undefined,
763+
): T | undefined {
764+
if (options?.logger !== undefined || logger === undefined) {
765+
return options;
766+
}
767+
768+
return options ? { ...options, logger } : ({ logger } as T);
769+
}

src/sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { BffApiClient } from "./bff/BffApiClient.js";
2+
export { noopLogger, type Logger, type LogLevel } from "./logger.js";
23
export {
34
LibrusSession,
45
type LibrusApiBackend,

0 commit comments

Comments
 (0)