Skip to content

Commit 95025ff

Browse files
authored
Improve logging foundation (#1907)
1 parent be55ae2 commit 95025ff

9 files changed

Lines changed: 302 additions & 289 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"livekit-client": patch
3+
---
4+
5+
chore: improve logging foundation for implicit context retrieval

LOGGING.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Logging conventions
2+
3+
This SDK uses [loglevel](https://github.com/pimterry/loglevel) via a thin
4+
wrapper in [`src/logger.ts`](src/logger.ts). Each subsystem gets its own
5+
named logger (see `LoggerNames`) so users can raise verbosity per area via
6+
`setLogLevel(level, loggerName)`.
7+
8+
## Level rubric
9+
10+
- **error** — failure surfaced to the user / unrecoverable. Gave up
11+
reconnecting, publish rejected by server, decode permanently failed.
12+
- **warn** — recoverable anomaly or automatic retry. ICE restart, signal
13+
reconnect starting, token refresh retryable failure, unexpected-but-
14+
handled server message.
15+
- **info** — exactly one log per meaningful lifecycle transition:
16+
`connecting` / `connected` / `reconnecting (attempt N)` / `reconnected` /
17+
`disconnected (reason)`, track `published` / `unpublished` /
18+
`subscribed` / `unsubscribed`, permission changes, region switched,
19+
e2ee enabled/disabled, signal (re)connected, major engine state
20+
transitions. Nothing that can fire more than about once per second
21+
under normal use.
22+
- **debug** — everything else: individual signal messages, per-ICE-
23+
candidate, SDP, DTX/simulcast/codec negotiation, data channel
24+
lifecycle, reconnection internal states, timing details.
25+
- **trace** — reserved for deliberate deep dives; unused by default.
26+
27+
## Structured context
28+
29+
Each class passes a structured `logContext` object to every log call so
30+
consumers wired up via `setLogExtension` receive full metadata for
31+
ingestion.
32+
33+
### Binding context to a logger
34+
35+
Prefer creating the named logger with a context provider once, then
36+
passing only call-site-specific extras:
37+
38+
```ts
39+
// in a class constructor
40+
this.log = getLogger(LoggerNames.Engine, () => this.logContext);
41+
42+
// at call sites
43+
this.log.debug('got ICE candidate from peer', { candidate, target });
44+
// devtools: got ICE candidate from peer, { room: 'foo', participant: 'alice', ..., candidate, target }
45+
// setLogExtension: { room: 'foo', participant: 'alice', ..., candidate, target }
46+
```
47+
48+
The context provider is invoked on every call, so updates to `logContext`
49+
are reflected automatically.
50+
51+

src/api/SignalClient.ts

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,8 @@ export class SignalClient {
247247
private useV0SignalPath = false;
248248

249249
constructor(useJSON: boolean = false, loggerOptions: LoggerOptions = {}) {
250-
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.Signal);
251250
this.loggerContextCb = loggerOptions.loggerContextCb;
251+
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.Signal, () => this.logContext);
252252
this.useJSON = useJSON;
253253
this.requestQueue = new AsyncQueue();
254254
this.queuedRequests = [];
@@ -284,10 +284,7 @@ export class SignalClient {
284284
reason?: ReconnectReason,
285285
): Promise<ReconnectResponse | undefined> {
286286
if (!this.options) {
287-
this.log.warn(
288-
'attempted to reconnect without signal options being set, ignoring',
289-
this.logContext,
290-
);
287+
this.log.warn('attempted to reconnect without signal options being set, ignoring');
291288
return;
292289
}
293290
this.state = SignalConnectionState.RECONNECTING;
@@ -377,10 +374,9 @@ export class SignalClient {
377374
if (redactedUrl.searchParams.has('access_token')) {
378375
redactedUrl.searchParams.set('access_token', '<redacted>');
379376
}
380-
this.log.debug(`connecting to ${redactedUrl}`, {
377+
this.log.info(`signal connecting to ${redactedUrl}`, {
381378
reconnect: opts.reconnect,
382379
reconnectReason: opts.reconnectReason,
383-
...this.logContext,
384380
});
385381
if (this.ws) {
386382
await this.close(false);
@@ -399,7 +395,6 @@ export class SignalClient {
399395
}
400396
if (closeInfo.closeCode !== 1000) {
401397
this.log.warn(`websocket closed`, {
402-
...this.logContext,
403398
reason: closeInfo.reason,
404399
code: closeInfo.closeCode,
405400
wasClean: closeInfo.closeCode === 1000,
@@ -466,7 +461,6 @@ export class SignalClient {
466461

467462
if (this.pingTimeoutDuration && this.pingTimeoutDuration > 0) {
468463
this.log.debug('ping config', {
469-
...this.logContext,
470464
timeout: this.pingTimeoutDuration,
471465
interval: this.pingIntervalDuration,
472466
});
@@ -555,7 +549,7 @@ export class SignalClient {
555549
await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]);
556550
}
557551
} catch (e) {
558-
this.log.debug('websocket error while closing', { ...this.logContext, error: e });
552+
this.log.debug('websocket error while closing', { error: e });
559553
} finally {
560554
if (updateState) {
561555
this.state = SignalConnectionState.DISCONNECTED;
@@ -566,7 +560,7 @@ export class SignalClient {
566560

567561
// initial offer after joining
568562
sendOffer(offer: RTCSessionDescriptionInit, offerId: number) {
569-
this.log.debug('sending offer', { ...this.logContext, offerSdp: offer.sdp });
563+
this.log.debug('sending offer', { offerSdp: offer.sdp });
570564
this.sendRequest({
571565
case: 'offer',
572566
value: toProtoSessionDescription(offer, offerId),
@@ -575,15 +569,15 @@ export class SignalClient {
575569

576570
// answer a server-initiated offer
577571
sendAnswer(answer: RTCSessionDescriptionInit, offerId: number) {
578-
this.log.debug('sending answer', { ...this.logContext, answerSdp: answer.sdp });
572+
this.log.debug('sending answer', { answerSdp: answer.sdp });
579573
return this.sendRequest({
580574
case: 'answer',
581575
value: toProtoSessionDescription(answer, offerId),
582576
});
583577
}
584578

585579
sendIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
586-
this.log.debug('sending ice candidate', { ...this.logContext, candidate });
580+
this.log.debug('sending ice candidate', { candidate });
587581
return this.sendRequest({
588582
case: 'trickle',
589583
value: new TrickleRequest({
@@ -768,10 +762,7 @@ export class SignalClient {
768762
return;
769763
}
770764
if (!this.streamWriter) {
771-
this.log.error(
772-
`cannot send signal request before connected, type: ${message?.case}`,
773-
this.logContext,
774-
);
765+
this.log.error(`cannot send signal request before connected, type: ${message?.case}`);
775766
return;
776767
}
777768
const req = new SignalRequest({ message });
@@ -783,14 +774,14 @@ export class SignalClient {
783774
await this.streamWriter.write(req.toBinary());
784775
}
785776
} catch (e) {
786-
this.log.error('error sending signal message', { ...this.logContext, error: e });
777+
this.log.error('error sending signal message', { error: e });
787778
}
788779
}
789780

790781
private handleSignalResponse(res: SignalResponse) {
791782
const msg = res.message;
792783
if (msg == undefined) {
793-
this.log.debug('received unsupported message', this.logContext);
784+
this.log.debug('received unsupported message');
794785
return;
795786
}
796787

@@ -899,7 +890,7 @@ export class SignalClient {
899890
this.onDataTrackSubscriberHandles(msg.value);
900891
}
901892
} else {
902-
this.log.debug('unsupported message', { ...this.logContext, msgCase: msg.case });
893+
this.log.debug('unsupported message', { msgCase: msg.case });
903894
}
904895

905896
if (!pingHandled) {
@@ -920,14 +911,14 @@ export class SignalClient {
920911
if (this.state === SignalConnectionState.DISCONNECTED) return;
921912
const onCloseCallback = this.onClose;
922913
await this.close(undefined, reason);
923-
this.log.debug(`websocket connection closed: ${reason}`, { ...this.logContext, reason });
914+
this.log.info(`websocket connection closed: ${reason}`, { reason });
924915
if (onCloseCallback) {
925916
onCloseCallback(reason);
926917
}
927918
}
928919

929920
private handleWSError(error: unknown) {
930-
this.log.error('websocket error', { ...this.logContext, error });
921+
this.log.error('websocket error', { error });
931922
}
932923

933924
/**
@@ -937,15 +928,14 @@ export class SignalClient {
937928
private resetPingTimeout() {
938929
this.clearPingTimeout();
939930
if (!this.pingTimeoutDuration) {
940-
this.log.warn('ping timeout duration not set', this.logContext);
931+
this.log.warn('ping timeout duration not set');
941932
return;
942933
}
943934
this.pingTimeout = CriticalTimers.setTimeout(() => {
944935
this.log.warn(
945936
`ping timeout triggered. last pong received at: ${new Date(
946937
Date.now() - this.pingTimeoutDuration! * 1000,
947938
).toUTCString()}`,
948-
this.logContext,
949939
);
950940
this.handleOnClose('ping timeout');
951941
}, this.pingTimeoutDuration * 1000);
@@ -964,17 +954,17 @@ export class SignalClient {
964954
this.clearPingInterval();
965955
this.resetPingTimeout();
966956
if (!this.pingIntervalDuration) {
967-
this.log.warn('ping interval duration not set', this.logContext);
957+
this.log.warn('ping interval duration not set');
968958
return;
969959
}
970-
this.log.debug('start ping interval', this.logContext);
960+
this.log.debug('start ping interval');
971961
this.pingInterval = CriticalTimers.setInterval(() => {
972962
this.sendPing();
973963
}, this.pingIntervalDuration * 1000);
974964
}
975965

976966
private clearPingInterval() {
977-
this.log.debug('clearing ping interval', this.logContext);
967+
this.log.debug('clearing ping interval');
978968
this.clearPingTimeout();
979969
if (this.pingInterval) {
980970
CriticalTimers.clearInterval(this.pingInterval);
@@ -994,6 +984,7 @@ export class SignalClient {
994984
firstMessage?: SignalResponse,
995985
) {
996986
this.state = SignalConnectionState.CONNECTED;
987+
this.log.info('signal connected');
997988
clearTimeout(timeoutHandle);
998989
this.startPingInterval();
999990
this.startReadingLoop(connection.readable.getReader(), firstMessage);
@@ -1031,10 +1022,7 @@ export class SignalClient {
10311022
};
10321023
} else {
10331024
// in reconnecting, any message received means signal reconnected and we still need to process it
1034-
this.log.debug(
1035-
'declaring signal reconnected without reconnect response received',
1036-
this.logContext,
1037-
);
1025+
this.log.debug('declaring signal reconnected without reconnect response received');
10381026
return {
10391027
isValid: true,
10401028
response: undefined,

src/logger.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as loglevel from 'loglevel';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import {
4+
type LogExtension,
5+
LogLevel,
6+
LoggerNames,
7+
type StructuredLogger,
8+
getLogger,
9+
setLogExtension,
10+
setLogLevel,
11+
} from './logger';
12+
13+
describe('getLogger with context provider', () => {
14+
afterEach(() => {
15+
setLogLevel(LogLevel.info);
16+
});
17+
18+
const hookBase = (name: LoggerNames, extension: LogExtension) => {
19+
const base = loglevel.getLogger(name) as StructuredLogger;
20+
setLogExtension(extension, base);
21+
};
22+
23+
it('omits the prefix when the bound context has no display keys', () => {
24+
const extension = vi.fn<LogExtension>();
25+
hookBase(LoggerNames.Room, extension);
26+
setLogLevel(LogLevel.info, LoggerNames.Room);
27+
28+
const log = getLogger(LoggerNames.Room, () => ({ irrelevant: 'x' }));
29+
log.info('plain');
30+
31+
expect(extension).toHaveBeenCalledWith(LogLevel.info, 'plain', { irrelevant: 'x' });
32+
});
33+
34+
it('reflects dynamic changes to the bound context on every call', () => {
35+
const extension = vi.fn<LogExtension>();
36+
hookBase(LoggerNames.Engine, extension);
37+
setLogLevel(LogLevel.info, LoggerNames.Engine);
38+
39+
let current: Record<string, string> = { room: 'r1' };
40+
const log = getLogger(LoggerNames.Engine, () => current);
41+
42+
log.info('first');
43+
current = { room: 'r2', participant: 'bob' };
44+
log.info('second');
45+
46+
const infos = extension.mock.calls.filter((c) => c[0] === LogLevel.info);
47+
expect(infos[0][2]).toEqual({ room: 'r1' });
48+
expect(infos[1][2]).toEqual({ room: 'r2', participant: 'bob' });
49+
});
50+
51+
it('returns an unwrapped logger when no context provider is supplied', () => {
52+
const extension = vi.fn<LogExtension>();
53+
hookBase(LoggerNames.Signal, extension);
54+
setLogLevel(LogLevel.info, LoggerNames.Signal);
55+
56+
const log = getLogger(LoggerNames.Signal);
57+
log.info('raw');
58+
59+
expect(extension).toHaveBeenCalledWith(LogLevel.info, 'raw', undefined);
60+
});
61+
});

src/logger.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export type StructuredLogger = log.Logger & {
3737
getLevel: () => number;
3838
};
3939

40-
let livekitLogger = log.getLogger('livekit');
40+
export type ContextProvider = () => object | undefined;
41+
42+
let livekitLogger = log.getLogger(LoggerNames.Default);
4143
const livekitLoggers = Object.values(LoggerNames).map((name) => log.getLogger(name));
4244

4345
livekitLogger.setDefaultLevel(LogLevel.info);
@@ -46,11 +48,43 @@ export default livekitLogger as StructuredLogger;
4648

4749
/**
4850
* @internal
51+
*
52+
* Get a named logger. When `ctxFn` is supplied, every log call
53+
* automatically:
54+
* 1. prepends a `[key=value ...]` prefix derived from `ctxFn()` to the
55+
* message string, so identifiers are visible in browser devtools
56+
* without expanding the structured context object, and
57+
* 2. merges `ctxFn()` into the structured context passed to any
58+
* `setLogExtension` consumer, so ingestion pipelines continue to
59+
* receive the full metadata unchanged.
4960
*/
50-
export function getLogger(name: string) {
61+
export function getLogger(name: string, ctxFn?: ContextProvider) {
5162
const logger = log.getLogger(name);
5263
logger.setDefaultLevel(livekitLogger.getLevel());
53-
return logger as StructuredLogger;
64+
if (!ctxFn) {
65+
return logger as StructuredLogger;
66+
}
67+
return wrapWithContext(logger as StructuredLogger, ctxFn);
68+
}
69+
70+
function wrapWithContext(base: StructuredLogger, ctxFn: ContextProvider): StructuredLogger {
71+
type LogMethod = 'trace' | 'debug' | 'info' | 'warn' | 'error';
72+
// Resolve the underlying method on every call so that later
73+
// setLogExtension installations (which replace the base logger's
74+
// methods via loglevel's methodFactory) are picked up.
75+
const wrap = (method: LogMethod) => (msg: string, extra?: object) => {
76+
const ctx = ctxFn();
77+
const merged = ctx || extra ? { ...ctx, ...extra } : undefined;
78+
base[method](msg, merged);
79+
};
80+
81+
const proxy = Object.create(base) as StructuredLogger;
82+
proxy.trace = wrap('trace');
83+
proxy.debug = wrap('debug');
84+
proxy.info = wrap('info');
85+
proxy.warn = wrap('warn');
86+
proxy.error = wrap('error');
87+
return proxy;
5488
}
5589

5690
export function setLogLevel(level: LogLevel | LogLevelString, loggerName?: LoggerNames) {
@@ -93,4 +127,4 @@ export function setLogExtension(extension: LogExtension, logger?: StructuredLogg
93127
});
94128
}
95129

96-
export const workerLogger = log.getLogger('lk-e2ee') as StructuredLogger;
130+
export const workerLogger = log.getLogger(LoggerNames.E2EE) as StructuredLogger;

0 commit comments

Comments
 (0)