Skip to content

Commit f373529

Browse files
authoredMar 10, 2024··
add Logger batching and structured logging (#2270)
1 parent 0f3d99c commit f373529

File tree

6 files changed

+451
-3
lines changed

6 files changed

+451
-3
lines changed
 

‎.changeset/flat-toys-flash.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
add structured logging apis
6+
7+
- Logger.json / Logger.jsonLogger
8+
- Logger.structured / Logger.structuredLogger
9+
10+
`Logger.json` logs JSON serialized strings to the console.
11+
12+
`Logger.structured` logs structured objects, which is useful in the browser
13+
where you can inspect objects logged to the console.

‎.changeset/sharp-bottles-add.md

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
add Logger.batched, for batching logger output
6+
7+
It takes a duration window and an effectful function that processes the batched output.
8+
9+
Example:
10+
11+
```ts
12+
import { Console, Effect, Logger } from "effect";
13+
14+
const LoggerLive = Logger.replaceScoped(
15+
Logger.defaultLogger,
16+
Logger.logfmtLogger.pipe(
17+
Logger.batched("500 millis", (messages) =>
18+
Console.log("BATCH", messages.join("\n"))
19+
)
20+
)
21+
);
22+
23+
Effect.gen(function* (_) {
24+
yield* _(Effect.log("one"));
25+
yield* _(Effect.log("two"));
26+
yield* _(Effect.log("three"));
27+
}).pipe(Effect.provide(LoggerLive), Effect.runFork);
28+
```

‎packages/effect/src/Logger.ts

+69
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @since 2.0.0
33
*/
44
import type * as Cause from "./Cause.js"
5+
import type { DurationInput } from "./Duration.js"
56
import type { Effect } from "./Effect.js"
67
import type * as FiberId from "./FiberId.js"
78
import type * as FiberRefs from "./FiberRefs.js"
@@ -158,6 +159,39 @@ export const map: {
158159
): Logger<Message, Output2>
159160
} = internal.map
160161

162+
/**
163+
* @since 2.0.0
164+
* @category mapping
165+
* @example
166+
* import { Console, Effect, Logger } from "effect";
167+
*
168+
* const LoggerLive = Logger.replaceScoped(
169+
* Logger.defaultLogger,
170+
* Logger.logfmtLogger.pipe(
171+
* Logger.batched("500 millis", (messages) =>
172+
* Console.log("BATCH", messages.join("\n"))
173+
* )
174+
* )
175+
* );
176+
*
177+
* Effect.gen(function* (_) {
178+
* yield* _(Effect.log("one"));
179+
* yield* _(Effect.log("two"));
180+
* yield* _(Effect.log("three"));
181+
* }).pipe(Effect.provide(LoggerLive), Effect.runFork);
182+
*/
183+
export const batched: {
184+
<Output, R>(
185+
window: DurationInput,
186+
f: (messages: Array<Types.NoInfer<Output>>) => Effect<void, never, R>
187+
): <Message>(self: Logger<Message, Output>) => Effect<Logger<Message, void>, never, R | Scope>
188+
<Message, Output, R>(
189+
self: Logger<Message, Output>,
190+
window: DurationInput,
191+
f: (messages: Array<Types.NoInfer<Output>>) => Effect<void, never, R>
192+
): Effect<Logger<Message, void>, never, Scope | R>
193+
} = fiberRuntime.batchedLogger
194+
161195
/**
162196
* A logger that does nothing in response to logging events.
163197
*
@@ -298,6 +332,12 @@ export const zipRight: {
298332
*/
299333
export const defaultLogger: Logger<unknown, void> = fiberRuntime.defaultLogger
300334

335+
/**
336+
* @since 2.0.0
337+
* @category constructors
338+
*/
339+
export const jsonLogger: Logger<unknown, string> = internal.jsonLogger
340+
301341
/**
302342
* @since 2.0.0
303343
* @category constructors
@@ -310,18 +350,47 @@ export const logfmtLogger: Logger<unknown, string> = internal.logfmtLogger
310350
*/
311351
export const stringLogger: Logger<unknown, string> = internal.stringLogger
312352

353+
/**
354+
* @since 2.0.0
355+
* @category constructors
356+
*/
357+
export const structuredLogger: Logger<
358+
unknown,
359+
{
360+
readonly logLevel: string
361+
readonly fiberId: string
362+
readonly timestamp: string
363+
readonly message: unknown
364+
readonly cause: string | undefined
365+
readonly annotations: Record<string, unknown>
366+
readonly spans: Record<string, number>
367+
}
368+
> = internal.structuredLogger
369+
313370
/**
314371
* @since 2.0.0
315372
* @category constructors
316373
*/
317374
export const tracerLogger: Logger<unknown, void> = fiberRuntime.tracerLogger
318375

376+
/**
377+
* @since 2.0.0
378+
* @category constructors
379+
*/
380+
export const json: Layer.Layer<never> = replace(fiberRuntime.defaultLogger, fiberRuntime.jsonLogger)
381+
319382
/**
320383
* @since 2.0.0
321384
* @category constructors
322385
*/
323386
export const logFmt: Layer.Layer<never> = replace(fiberRuntime.defaultLogger, fiberRuntime.logFmtLogger)
324387

388+
/**
389+
* @since 2.0.0
390+
* @category constructors
391+
*/
392+
export const structured: Layer.Layer<never> = replace(fiberRuntime.defaultLogger, fiberRuntime.structuredLogger)
393+
325394
/**
326395
* @since 2.0.0
327396
* @category context

‎packages/effect/src/internal/fiberRuntime.ts

+69
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type * as Clock from "../Clock.js"
55
import type { ConfigProvider } from "../ConfigProvider.js"
66
import * as Context from "../Context.js"
77
import * as Deferred from "../Deferred.js"
8+
import type * as Duration from "../Duration.js"
89
import type * as Effect from "../Effect.js"
910
import { EffectTypeId } from "../Effectable.js"
1011
import type * as Either from "../Either.js"
@@ -1361,6 +1362,16 @@ export const defaultLogger: Logger<unknown, void> = globalValue(
13611362
})
13621363
)
13631364

1365+
/** @internal */
1366+
export const jsonLogger: Logger<unknown, void> = globalValue(
1367+
Symbol.for("effect/Logger/jsonLogger"),
1368+
() =>
1369+
internalLogger.makeLogger((options) => {
1370+
const formatted = internalLogger.jsonLogger.log(options)
1371+
getConsole(options.context).log(formatted)
1372+
})
1373+
)
1374+
13641375
/** @internal */
13651376
export const logFmtLogger: Logger<unknown, void> = globalValue(
13661377
Symbol.for("effect/Logger/logFmtLogger"),
@@ -1371,6 +1382,16 @@ export const logFmtLogger: Logger<unknown, void> = globalValue(
13711382
})
13721383
)
13731384

1385+
/** @internal */
1386+
export const structuredLogger: Logger<unknown, void> = globalValue(
1387+
Symbol.for("effect/Logger/structuredLogger"),
1388+
() =>
1389+
internalLogger.makeLogger((options) => {
1390+
const formatted = internalLogger.structuredLogger.log(options)
1391+
getConsole(options.context).log(formatted)
1392+
})
1393+
)
1394+
13741395
/** @internal */
13751396
export const tracerLogger = globalValue(
13761397
Symbol.for("effect/Logger/tracerLogger"),
@@ -1434,6 +1455,54 @@ export const currentLoggers: FiberRef.FiberRef<
14341455
() => core.fiberRefUnsafeMakeHashSet(HashSet.make(defaultLogger, tracerLogger))
14351456
)
14361457

1458+
/** @internal */
1459+
export const batchedLogger = dual<
1460+
<Output, R>(
1461+
window: Duration.DurationInput,
1462+
f: (messages: Array<NoInfer<Output>>) => Effect.Effect<void, never, R>
1463+
) => <Message>(
1464+
self: Logger<Message, Output>
1465+
) => Effect.Effect<Logger<Message, void>, never, Scope.Scope | R>,
1466+
<Message, Output, R>(
1467+
self: Logger<Message, Output>,
1468+
window: Duration.DurationInput,
1469+
f: (messages: Array<NoInfer<Output>>) => Effect.Effect<void, never, R>
1470+
) => Effect.Effect<Logger<Message, void>, never, Scope.Scope | R>
1471+
>(3, <Message, Output, R>(
1472+
self: Logger<Message, Output>,
1473+
window: Duration.DurationInput,
1474+
f: (messages: Array<NoInfer<Output>>) => Effect.Effect<void, never, R>
1475+
): Effect.Effect<Logger<Message, void>, never, Scope.Scope | R> =>
1476+
core.flatMap(scope, (scope) => {
1477+
let buffer: Array<Output> = []
1478+
const flush = core.suspend(() => {
1479+
if (buffer.length === 0) {
1480+
return core.unit
1481+
}
1482+
const arr = buffer
1483+
buffer = []
1484+
return f(arr)
1485+
})
1486+
1487+
return core.uninterruptibleMask((restore) =>
1488+
pipe(
1489+
internalEffect.sleep(window),
1490+
core.zipRight(flush),
1491+
internalEffect.forever,
1492+
restore,
1493+
forkDaemon,
1494+
core.flatMap((fiber) => core.scopeAddFinalizer(scope, core.interruptFiber(fiber))),
1495+
core.zipRight(addFinalizer(() => flush)),
1496+
core.as(
1497+
internalLogger.makeLogger((options) => {
1498+
buffer.push(self.log(options))
1499+
console.log(buffer)
1500+
})
1501+
)
1502+
)
1503+
)
1504+
}))
1505+
14371506
// circular with Effect
14381507

14391508
/* @internal */

‎packages/effect/src/internal/logger.ts

+71-1
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export const stringLogger: Logger.Logger<unknown, string> = makeLogger<unknown,
215215

216216
export const serializeUnknown = (u: unknown): string => {
217217
try {
218-
return typeof u === "object" ? JSON.stringify(u) : String(u)
218+
return typeof u === "object" ? jsonStringifyCircular(u) : String(u)
219219
} catch (_) {
220220
return String(u)
221221
}
@@ -288,6 +288,76 @@ export const logfmtLogger = makeLogger<unknown, string>(
288288
}
289289
)
290290

291+
/** @internal */
292+
export const structuredLogger = makeLogger<unknown, {
293+
readonly logLevel: string
294+
readonly fiberId: string
295+
readonly timestamp: string
296+
readonly message: unknown
297+
readonly cause: string | undefined
298+
readonly annotations: Record<string, unknown>
299+
readonly spans: Record<string, number>
300+
}>(
301+
({ annotations, cause, date, fiberId, logLevel, message, spans }) => {
302+
const now = date.getTime()
303+
const annotationsObj: Record<string, unknown> = {}
304+
const spansObj: Record<string, number> = {}
305+
306+
if (HashMap.size(annotations) > 0) {
307+
for (const [k, v] of annotations) {
308+
annotationsObj[k] = structuredMessage(v)
309+
}
310+
}
311+
312+
if (List.isCons(spans)) {
313+
for (const span of spans) {
314+
spansObj[span.label] = now - span.startTime
315+
}
316+
}
317+
318+
return {
319+
message: structuredMessage(message),
320+
logLevel: logLevel.label,
321+
timestamp: date.toISOString(),
322+
cause: Cause.isEmpty(cause) ? undefined : Cause.pretty(cause),
323+
annotations: annotationsObj,
324+
spans: spansObj,
325+
fiberId: _fiberId.threadName(fiberId)
326+
}
327+
}
328+
)
329+
330+
export const structuredMessage = (u: unknown): unknown => {
331+
switch (typeof u) {
332+
case "bigint":
333+
case "function":
334+
case "symbol": {
335+
return String(u)
336+
}
337+
default: {
338+
return u
339+
}
340+
}
341+
}
342+
343+
const jsonStringifyCircular = (obj: unknown) => {
344+
let cache: Array<unknown> = []
345+
const retVal = JSON.stringify(
346+
obj,
347+
(_key, value) =>
348+
typeof value === "object" && value !== null
349+
? cache.includes(value)
350+
? undefined // circular reference
351+
: cache.push(value) && value
352+
: value
353+
)
354+
;(cache as any) = undefined
355+
return retVal
356+
}
357+
358+
/** @internal */
359+
export const jsonLogger = map(structuredLogger, jsonStringifyCircular)
360+
291361
/** @internal */
292362
const filterKeyName = (key: string) => key.replace(/[\s="]/g, "_")
293363

‎packages/effect/test/Logger.test.ts

+201-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Cause from "effect/Cause"
22
import * as Chunk from "effect/Chunk"
3+
import * as Effect from "effect/Effect"
34
import * as FiberId from "effect/FiberId"
45
import * as FiberRefs from "effect/FiberRefs"
56
import { identity } from "effect/Function"
@@ -8,7 +9,7 @@ import { logLevelInfo } from "effect/internal/core"
89
import * as List from "effect/List"
910
import * as Logger from "effect/Logger"
1011
import * as LogSpan from "effect/LogSpan"
11-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
12+
import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"
1213

1314
describe("stringLogger", () => {
1415
beforeEach(() => {
@@ -183,7 +184,7 @@ describe("logfmtLogger", () => {
183184
})
184185

185186
expect(result).toEqual(
186-
`timestamp=${date.toJSON()} level=INFO fiber= message="[object Object]"`
187+
`timestamp=${date.toJSON()} level=INFO fiber= message="{\\"hello\\":\\"world\\"}"`
187188
)
188189
})
189190

@@ -248,4 +249,202 @@ describe("logfmtLogger", () => {
248249
`timestamp=${date.toJSON()} level=INFO fiber= message="hello world" hashmap="{\\"_id\\":\\"HashMap\\",\\"values\\":[[\\"key\\",2]]}" chunk="{\\"_id\\":\\"Chunk\\",\\"values\\":[1,2]}"`
249250
)
250251
})
252+
253+
it("batched", () =>
254+
Effect.gen(function*(_) {
255+
const chunks: Array<Array<string>> = []
256+
const date = new Date()
257+
vi.setSystemTime(date)
258+
const logger = yield* _(
259+
Logger.logfmtLogger,
260+
Logger.batched("100 millis", (_) =>
261+
Effect.sync(() => {
262+
chunks.push(_)
263+
}))
264+
)
265+
const log = (message: string) =>
266+
logger.log({
267+
fiberId: FiberId.none,
268+
logLevel: logLevelInfo,
269+
message,
270+
cause: Cause.empty,
271+
context: FiberRefs.unsafeMake(new Map()),
272+
spans: List.empty(),
273+
annotations: HashMap.empty(),
274+
date
275+
})
276+
277+
log("a")
278+
log("b")
279+
log("c")
280+
yield* _(Effect.promise(() => vi.advanceTimersByTimeAsync(100)))
281+
log("d")
282+
log("e")
283+
yield* _(Effect.promise(() => vi.advanceTimersByTimeAsync(100)))
284+
285+
assert.deepStrictEqual(chunks, [
286+
[
287+
`timestamp=${date.toISOString()} level=INFO fiber= message=a`,
288+
`timestamp=${date.toISOString()} level=INFO fiber= message=b`,
289+
`timestamp=${date.toISOString()} level=INFO fiber= message=c`
290+
],
291+
[
292+
`timestamp=${date.toISOString()} level=INFO fiber= message=d`,
293+
`timestamp=${date.toISOString()} level=INFO fiber= message=e`
294+
]
295+
])
296+
}).pipe(Effect.scoped, Effect.runPromise))
297+
})
298+
299+
describe("jsonLogger", () => {
300+
beforeEach(() => {
301+
vi.useFakeTimers()
302+
})
303+
afterEach(() => {
304+
vi.useRealTimers()
305+
})
306+
307+
it("keys with special chars", () => {
308+
const date = new Date()
309+
vi.setSystemTime(date)
310+
const spans = List.make(LogSpan.make("imma span=\"", date.getTime() - 7))
311+
const annotations = HashMap.make(
312+
["just_a_key", "just_a_value"],
313+
["I am bad key name", { coolValue: "cool value" }],
314+
["good_key", "I am a good value"],
315+
["good_bool", true],
316+
["good_number", 123]
317+
)
318+
319+
const result = Logger.jsonLogger.log({
320+
fiberId: FiberId.none,
321+
logLevel: logLevelInfo,
322+
message: "My message",
323+
cause: Cause.empty,
324+
context: FiberRefs.unsafeMake(new Map()),
325+
spans,
326+
annotations,
327+
date
328+
})
329+
330+
expect(result).toEqual(
331+
JSON.stringify({
332+
message: "My message",
333+
logLevel: "INFO",
334+
timestamp: date.toJSON(),
335+
annotations: {
336+
just_a_key: "just_a_value",
337+
good_key: "I am a good value",
338+
good_bool: true,
339+
"I am bad key name": { coolValue: "cool value" },
340+
good_number: 123
341+
},
342+
spans: { "imma span=\"": 7 },
343+
fiberId: ""
344+
})
345+
)
346+
})
347+
348+
it("objects", () => {
349+
const date = new Date()
350+
vi.setSystemTime(date)
351+
352+
const result = Logger.jsonLogger.log({
353+
fiberId: FiberId.none,
354+
logLevel: logLevelInfo,
355+
message: { hello: "world" },
356+
cause: Cause.empty,
357+
context: FiberRefs.unsafeMake(new Map()),
358+
spans: List.empty(),
359+
annotations: HashMap.empty(),
360+
date
361+
})
362+
363+
expect(result).toEqual(JSON.stringify({
364+
message: { hello: "world" },
365+
logLevel: "INFO",
366+
timestamp: date.toJSON(),
367+
annotations: {},
368+
spans: {},
369+
fiberId: ""
370+
}))
371+
})
372+
373+
it("circular objects", () => {
374+
const date = new Date()
375+
vi.setSystemTime(date)
376+
377+
const msg: Record<string, any> = { hello: "world" }
378+
msg.msg = msg
379+
380+
const result = Logger.jsonLogger.log({
381+
fiberId: FiberId.none,
382+
logLevel: logLevelInfo,
383+
message: msg,
384+
cause: Cause.empty,
385+
context: FiberRefs.unsafeMake(new Map()),
386+
spans: List.empty(),
387+
annotations: HashMap.empty(),
388+
date
389+
})
390+
391+
expect(result).toEqual(JSON.stringify({
392+
message: { hello: "world" },
393+
logLevel: "INFO",
394+
timestamp: date.toJSON(),
395+
annotations: {},
396+
spans: {},
397+
fiberId: ""
398+
}))
399+
})
400+
401+
it("symbols", () => {
402+
const date = new Date()
403+
vi.setSystemTime(date)
404+
405+
const result = Logger.jsonLogger.log({
406+
fiberId: FiberId.none,
407+
logLevel: logLevelInfo,
408+
message: Symbol.for("effect/Logger/test"),
409+
cause: Cause.empty,
410+
context: FiberRefs.unsafeMake(new Map()),
411+
spans: List.empty(),
412+
annotations: HashMap.empty(),
413+
date
414+
})
415+
416+
expect(result).toEqual(JSON.stringify({
417+
message: Symbol.for("effect/Logger/test").toString(),
418+
logLevel: "INFO",
419+
timestamp: date.toJSON(),
420+
annotations: {},
421+
spans: {},
422+
fiberId: ""
423+
}))
424+
})
425+
426+
it("functions", () => {
427+
const date = new Date()
428+
vi.setSystemTime(date)
429+
430+
const result = Logger.jsonLogger.log({
431+
fiberId: FiberId.none,
432+
logLevel: logLevelInfo,
433+
message: () => "hello world",
434+
cause: Cause.empty,
435+
context: FiberRefs.unsafeMake(new Map()),
436+
spans: List.empty(),
437+
annotations: HashMap.empty(),
438+
date
439+
})
440+
441+
expect(result).toEqual(JSON.stringify({
442+
message: "() => \"hello world\"",
443+
logLevel: "INFO",
444+
timestamp: date.toJSON(),
445+
annotations: {},
446+
spans: {},
447+
fiberId: ""
448+
}))
449+
})
251450
})

0 commit comments

Comments
 (0)
Please sign in to comment.