logquill 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,11 +10,12 @@ JSON log shape with its Python sibling, [`logquill`](https://pypi.org/project/lo
10
10
  (repo: `logquill-python`).
11
11
 
12
12
  Status: pre-release, under active development. The core `Logger`, level
13
- filtering, a full plugin pipeline (context/redaction/PII/tamper-evidence/
14
- sampling/alerting), `JSONFormatter`, and a broad transport catalog —
15
- console/file/HTTP, SQL, NoSQL, message queues, and cloud-native log
16
- platforms are implemented; non-blocking async dispatch is not yet see
17
- `CHANGELOG.md` for what's landed so far.
13
+ filtering, non-blocking async dispatch with configurable backpressure, a
14
+ full plugin pipeline (context/redaction/PII/tamper-evidence/sampling/
15
+ alerting), `JSONFormatter`, a broad transport catalog console/file/
16
+ HTTP, SQL, NoSQL, message queues, and cloud-native log platformsand a
17
+ separate `logquill/browser` build are implemented; see `CHANGELOG.md` for
18
+ what's landed so far.
18
19
 
19
20
  ## Features
20
21
 
@@ -26,7 +27,12 @@ platforms — are implemented; non-blocking async dispatch is not yet — see
26
27
  - **Child loggers** — `.child()` inherits level, transports, and plugins, and merges its own `meta` on top
27
28
  - **Typed throughout** — TypeScript strict mode, no `any` in the public API
28
29
  - **Dual package** — works via both `require()` (CJS) and `import` (ESM) from the same published package
29
- - *(planned)* non-blocking async dispatch, `AsyncLocalStorage`-based context propagation — see `CHANGELOG.md`
30
+ - **Tracing & agentic logging** — `.thought()/.action()/.observation()/.decision()`, `Logger.span()` for nested/durationMs-stamped spans, `RunPlugin` (per-run id + step counter), and `TraceContextPlugin` (cross-service `traceId`, OTel-aware) — see [Tracing & agentic logging](#tracing--agentic-logging)
31
+ - **LangChain.js / LangGraph.js adapter** — `LangChainAdapter`, a `BaseCallbackHandler` that maps chain/LLM/tool/agent events onto the calls above with zero manual instrumentation, from a separate `logquill/langchain` entry point — see [Agentic framework adapters](#agentic-framework-adapters)
32
+ - **Non-blocking async dispatch** — a call returns before its write runs, via a bounded internal queue with a configurable backpressure policy (`dropOldest`/`dropNewest`/`block`); `logger.flush()`, `withLambda`/`withCloudFunction`/`withAzureFunction`, and `installShutdownHandlers` cover draining it before a process pauses or exits — see [Async dispatch & shutdown](#async-dispatch--shutdown)
33
+ - **Request-scoped context propagation** — `bindContext()`, `AsyncLocalStorage`-backed, so a value set once is visible in every nested log call underneath it without threading it through every signature by hand; `RateLimitPlugin` caps a noisy loop without silencing everything else — see [Context propagation & error capture](#context-propagation--error-capture)
34
+ - **Migration bridges** — `LogQuillWinstonTransport` (from `logquill/winston`) and `LogQuillPinoDestination` let an existing `winston`/`pino` app adopt LogQuill's transports/plugins with no call-site changes — see [Migration bridges](#migration-bridges)
35
+ - **Browser build** — a separate `logquill/browser` entry (`Logger`, `JSONFormatter`, the plugin pipeline, `ConsoleTransport`, `BeaconTransport`) with no Node built-ins in its module graph — see [Browser build](#browser-build)
30
36
 
31
37
  ## Contents
32
38
 
@@ -42,6 +48,15 @@ platforms — are implemented; non-blocking async dispatch is not yet — see
42
48
  - [PII redaction](#pii-redaction)
43
49
  - [Tamper-evident logs](#tamper-evident-logs)
44
50
  - [Alerting](#alerting)
51
+ - [Rate limiting](#rate-limiting)
52
+ - [Tracing & agentic logging](#tracing--agentic-logging)
53
+ - [Agentic framework adapters](#agentic-framework-adapters)
54
+ - [Async dispatch & shutdown](#async-dispatch--shutdown)
55
+ - [Kubernetes](#kubernetes)
56
+ - [Context propagation & error capture](#context-propagation--error-capture)
57
+ - [Migration bridges](#migration-bridges)
58
+ - [Browser build](#browser-build)
59
+ - [API reference](#api-reference)
45
60
  - [Development](#development)
46
61
  - [License](#license)
47
62
 
@@ -115,6 +130,7 @@ actually import; nothing is installed on your behalf.
115
130
  | Transport | Backend | Peer dependency | Setup |
116
131
  |---|---|---|---|
117
132
  | `ConsoleTransport` | stdout/stderr via `console.*` | *(none)* | zero-setup, isomorphic |
133
+ | `BeaconTransport` | browser log endpoint, via `navigator.sendBeacon` | *(none, isomorphic)* | zero-setup — see [Browser build](#browser-build) |
118
134
  | `FileTransport` | local file, with rotation | *(none)* | zero-setup |
119
135
  | `HTTPTransport` | any HTTP log endpoint | *(none, uses `fetch`)* | zero-setup |
120
136
  | `SQLiteTransport` | SQLite | `better-sqlite3` | zero-setup (file or `:memory:`) |
@@ -502,6 +518,375 @@ logger.error("payment webhook failed", { orderId: "o-123" });
502
518
  inject a `sender`) follow the same shape. Write your own by extending
503
519
  `AlertingPlugin` and implementing `sendAlert(record, occurrences)`.
504
520
 
521
+ ### Rate limiting
522
+
523
+ `RateLimitPlugin` drops records once a key — by default `(logger, level)`
524
+ — exceeds `maxRecords` within a rolling `perSeconds` window, so a noisy
525
+ loop (a retry logging the same error every iteration) can't drown out a
526
+ logger's other messages. Each key gets its own window, so unrelated keys
527
+ never reset in lockstep; pass `keyFunc` to key on something else, e.g. an
528
+ error message or a `meta` field identifying the caller.
529
+
530
+ ```ts
531
+ import { Logger, RateLimitPlugin } from "logquill";
532
+
533
+ const logger = new Logger("app", { plugins: [new RateLimitPlugin(5, 60)] }); // at most 5 per key per minute
534
+
535
+ for (let i = 0; i < 10; i++) {
536
+ logger.error("db connection failed"); // only the first 5 in any 60s window ship
537
+ }
538
+ ```
539
+
540
+ ## Tracing & agentic logging
541
+
542
+ `.thought()/.action()/.observation()/.decision()` are `.info()` with
543
+ `meta.kind` pre-set, for tagging steps of an agent's reasoning loop:
544
+
545
+ ```ts
546
+ import { Logger } from "logquill";
547
+
548
+ const logger = new Logger("agent");
549
+ logger.thought("deciding which tool to call", { candidates: ["search", "calculator"] });
550
+ logger.action("calling search", { query: "current weather in nyc" });
551
+ logger.observation("search returned 3 results");
552
+ logger.decision("using search result #1");
553
+ ```
554
+
555
+ `Logger.span()` wraps an operation, emitting one record on completion with
556
+ `meta.spanId`/`meta.durationMs` — every record logged inside it, including
557
+ across an `await`, is automatically stamped with `meta.parentSpanId`, so a
558
+ full run's nesting can be reconstructed by sorting on `spanId`/`parentSpanId`.
559
+ It still emits (at `ERROR`, with `meta.error`) and rethrows if the block
560
+ throws:
561
+
562
+ ```ts
563
+ import { Logger } from "logquill";
564
+
565
+ async function callLlm(prompt: string): Promise<string> {
566
+ return `response to: ${prompt}`;
567
+ }
568
+
569
+ const logger = new Logger("agent");
570
+
571
+ const answer = await logger.span(
572
+ "callLlm",
573
+ async () => {
574
+ logger.action("requesting completion", { model: "gpt-4" });
575
+ const response = await callLlm("current weather in nyc");
576
+ logger.observation("received completion");
577
+ return response;
578
+ },
579
+ { model: "gpt-4" },
580
+ );
581
+ ```
582
+
583
+ `RunPlugin` stamps `meta.runId` (generated, or given explicitly) plus an
584
+ incrementing `meta.step` — attach a fresh instance per run so concurrent
585
+ runs don't share a counter:
586
+
587
+ ```ts
588
+ import { Logger, RunPlugin } from "logquill";
589
+
590
+ const runLogger = new Logger("app").child("agent").use(new RunPlugin());
591
+ runLogger.thought("step one"); // meta: { kind: "thought", runId: "...", step: 0 }
592
+ runLogger.action("step two"); // meta: { kind: "action", runId: "...", step: 1 }
593
+ ```
594
+
595
+ `TraceContextPlugin` stamps `meta.traceId`, for correlating one request
596
+ across services — distinct from `runId`, which scopes one agent run. It
597
+ resolves, in priority order: an active OpenTelemetry span (if
598
+ `@opentelemetry/api` is installed — never a required dependency), an
599
+ inbound `traceparent`/X-Ray/GCP trace header, or a freshly generated id:
600
+
601
+ ```ts
602
+ import { Logger, setTraceparent, TraceContextPlugin } from "logquill";
603
+
604
+ const logger = new Logger("app", { plugins: [new TraceContextPlugin()] });
605
+
606
+ // in HTTP middleware, before the handler runs:
607
+ const reset = setTraceparent(req.headers["traceparent"]);
608
+ try {
609
+ logger.info("handling request"); // meta.traceId resolved from the inbound header
610
+ } finally {
611
+ reset();
612
+ }
613
+ ```
614
+
615
+ ## Agentic framework adapters
616
+
617
+ `LangChainAdapter` implements LangChain.js's `BaseCallbackHandler`, mapping
618
+ chain/LLM/tool/agent events onto `.action()/.observation()/.decision()` and
619
+ `span()`-shaped records — pass it into `callbacks: [...]` and a chain's
620
+ full call tree is captured with zero manual instrumentation:
621
+
622
+ ```ts
623
+ import { RunnableLambda } from "@langchain/core/runnables";
624
+ import { Logger, RunPlugin } from "logquill";
625
+ import { LangChainAdapter } from "logquill/langchain";
626
+
627
+ const logger = new Logger("agent").use(new RunPlugin());
628
+ const handler = new LangChainAdapter(logger);
629
+
630
+ const answerQuestion = RunnableLambda.from((question: string) => `answer: ${question}`);
631
+ await answerQuestion.invoke("what is 2+2?", { callbacks: [handler] });
632
+ // logs one record: { message: "RunnableLambda", meta: { kind: "span", spanId: "...", durationMs: ... } }
633
+ ```
634
+
635
+ `LangGraphAdapter` is the same handler under its own name, for LangGraph.js
636
+ graphs — its nodes run as ordinary LangChain `Runnable`s, so no extra
637
+ mapping is needed; pass it the same way:
638
+
639
+ ```ts
640
+ import { LangGraphAdapter } from "logquill/langchain";
641
+
642
+ const handler = new LangGraphAdapter(logger);
643
+ // const graph = builder.compile({ checkpointer });
644
+ // await graph.invoke(input, { callbacks: [handler], configurable: { thread_id: "1" } });
645
+ ```
646
+
647
+ This is a **separate entry point** — `import ... from "logquill/langchain"`,
648
+ not the main `"logquill"` import — because `LangChainAdapter` has to
649
+ `extends BaseCallbackHandler`, LangChain's own class. Importing plain
650
+ `logquill` never touches `@langchain/core`; only importing
651
+ `logquill/langchain` does. Install `@langchain/core` yourself (it's an
652
+ optional peer dependency) — no separate `@langchain/langgraph` dependency
653
+ is needed for `LangGraphAdapter`.
654
+
655
+ ## Async dispatch & shutdown
656
+
657
+ Every `Logger` call returns before the write it triggers actually runs —
658
+ the write (and any plugin `afterLog` hooks) is handed to an internal,
659
+ bounded dispatch queue, drained outside the caller's own call stack:
660
+
661
+ ```ts
662
+ import { CollectingTransport, Logger } from "logquill";
663
+
664
+ const transport = new CollectingTransport();
665
+ const logger = new Logger("app", { transports: [transport] });
666
+
667
+ logger.info("hello");
668
+ console.log(transport.records.length); // 0 — still queued
669
+ await logger.flush();
670
+ console.log(transport.records.length); // 1 — now written
671
+ ```
672
+
673
+ The queue has a configurable size and backpressure policy, so a sustained
674
+ burst can't grow memory without bound:
675
+
676
+ ```ts
677
+ const logger = new Logger("app", {
678
+ transports: [transport],
679
+ queue: {
680
+ maxSize: 10_000, // default
681
+ policy: "dropOldest", // "dropOldest" (default) | "dropNewest" | "block"
682
+ },
683
+ });
684
+ ```
685
+
686
+ - `"dropOldest"` — once the queue is full, the longest-waiting record is
687
+ discarded to make room for the new one.
688
+ - `"dropNewest"` — the incoming record is discarded; everything already
689
+ queued is left alone.
690
+ - `"block"` — nothing is ever dropped: once the queue is full, a call runs
691
+ its write immediately, on the caller's own stack, instead of queueing it.
692
+
693
+ Any dropped-record policy calls `onDrop(count, policy)` — rate-limited to
694
+ once per `warnIntervalMs` (default 5000) — so sustained overload is
695
+ visible without flooding your own logs with one warning per drop.
696
+
697
+ `logger.flush()` waits for every record dispatched so far to reach its
698
+ transports. Note it does *not* force a batching transport (SQL, a queue,
699
+ `HTTPTransport`, ...) to send a batch still under its own threshold early
700
+ — for that, use `withLambda`/`withCloudFunction`/`withAzureFunction`
701
+ (same wrapper, three platform-matching names) around a serverless handler,
702
+ which additionally forces every batching transport's buffer out before
703
+ the wrapped function's result settles — important because a frozen or
704
+ recycled execution environment may never come back to finish a partial
705
+ batch on its own:
706
+
707
+ ```ts
708
+ import { withLambda } from "logquill";
709
+
710
+ export const handler = withLambda(logger, async (event) => {
711
+ logger.info("handling request", { requestId: event.requestId });
712
+ return { statusCode: 200 };
713
+ });
714
+ ```
715
+
716
+ For a long-running process, `installShutdownHandlers` flushes and closes a
717
+ logger once on `SIGTERM`/`SIGINT`/`beforeExit`, so nothing queued is lost
718
+ when the process stops:
719
+
720
+ ```ts
721
+ import { installShutdownHandlers } from "logquill";
722
+
723
+ installShutdownHandlers(logger); // Node only
724
+ ```
725
+
726
+ ### Kubernetes
727
+
728
+ Default to `ConsoleTransport`, not `FileTransport`, for anything running in
729
+ a container. A container's filesystem is ephemeral and invisible to the
730
+ rest of the cluster — a log file written inside it disappears the moment
731
+ the pod is rescheduled, and nothing aggregates it in the meantime unless
732
+ you also run a sidecar to tail it back out. Writing to stdout/stderr
733
+ instead costs nothing extra: every major container runtime already
734
+ captures both streams, and the node-level log agent your cluster runs
735
+ (Fluentd, Fluent Bit, Vector, or your cloud provider's own) ships them to
736
+ your aggregator without any code on your side that needs to know that
737
+ agent exists:
738
+
739
+ ```ts
740
+ import { ConsoleTransport, Logger } from "logquill";
741
+
742
+ const logger = new Logger("app", { transports: [new ConsoleTransport()] });
743
+ ```
744
+
745
+ Kubernetes sends `SIGTERM` and then kills the process after
746
+ `terminationGracePeriodSeconds` (30s by default) regardless of whether
747
+ it's finished shutting down, so a record still sitting in the dispatch
748
+ queue at that instant can be lost if nothing catches the signal.
749
+ `installShutdownHandlers(logger)` closes that gap the same way
750
+ `withLambda` closes it for a serverless freeze — call it once, on process
751
+ start, so a pod that's told to stop still flushes and closes the logger
752
+ before Kubernetes kills it.
753
+
754
+ ## Context propagation & error capture
755
+
756
+ `bindContext()` merges values into a request-scoped context, backed by
757
+ `AsyncLocalStorage`: every `Logger` call underneath it — through any
758
+ number of function calls and `await`s deep — picks them up in `meta`
759
+ automatically, without threading them through every signature by hand.
760
+ Concurrent async operations sharing one `Logger` never see each other's
761
+ bound context.
762
+
763
+ ```ts
764
+ import { bindContext, Logger } from "logquill";
765
+
766
+ const logger = new Logger("app");
767
+
768
+ await bindContext({ requestId: "abc123" }, async () => {
769
+ await handleRequest(); // any logging in here, or in what it calls,
770
+ // gets meta.requestId = "abc123" for free
771
+ });
772
+
773
+ function handleRequest() {
774
+ logger.info("handled"); // meta: { requestId: "abc123" }
775
+ }
776
+ ```
777
+
778
+ Nested `bindContext()` calls merge, with the inner value winning on key
779
+ collision — the same way an explicit call-site `meta` value always wins
780
+ over anything bound this way.
781
+
782
+ Pass an `Error` as `meta.err` and it's replaced with a formatted
783
+ `meta.stack`, the same shape `Logger.span()` already produces internally
784
+ for a thrown error:
785
+
786
+ ```ts
787
+ try {
788
+ await riskyOperation();
789
+ } catch (err) {
790
+ logger.error("operation failed", { err, orderId: "o-123" });
791
+ // meta: { orderId: "o-123", stack: "Error: ...\n at ..." } — no meta.err
792
+ }
793
+ ```
794
+
795
+ ## Migration bridges
796
+
797
+ For an app already using `winston` or `pino`, LogQuill can sit alongside
798
+ either with no call-site changes, so a migration can happen transport by
799
+ transport instead of all at once.
800
+
801
+ `LogQuillWinstonTransport` (from the separate `logquill/winston` entry
802
+ point, since it has to extend `winston-transport`'s own class) plugs a
803
+ LogQuill `Logger` into an existing `winston.createLogger()` as one more
804
+ transport:
805
+
806
+ ```ts
807
+ import winston from "winston";
808
+ import { Logger } from "logquill";
809
+ import { LogQuillWinstonTransport } from "logquill/winston";
810
+
811
+ const logquill = new Logger("app");
812
+ const winstonLogger = winston.createLogger({
813
+ transports: [new LogQuillWinstonTransport(logquill)],
814
+ });
815
+
816
+ winstonLogger.info("still works exactly as before", { userId: 42 });
817
+ ```
818
+
819
+ `LogQuillPinoDestination` is a pino `destination` — pass it straight into
820
+ `pino()` and pino's own NDJSON output is parsed back into LogQuill calls.
821
+ It needs no dependency on `pino` itself (a destination only has to be
822
+ duck-type compatible with a Node `Writable`), so it ships from the main
823
+ entry point:
824
+
825
+ ```ts
826
+ import pino from "pino";
827
+ import { Logger, LogQuillPinoDestination } from "logquill";
828
+
829
+ const logquill = new Logger("app");
830
+ const log = pino(new LogQuillPinoDestination(logquill));
831
+
832
+ log.info({ userId: 42 }, "still works exactly as before");
833
+ ```
834
+
835
+ Both re-filter by the LogQuill `Logger`'s own `level` after the
836
+ source library's own filtering runs — the stricter of the two wins — and
837
+ map that library's levels onto LogQuill's via a `levelMap` option,
838
+ overridable for a non-default level configuration.
839
+
840
+ ## Browser build
841
+
842
+ `import ... from "logquill/browser"` is a separate entry point shipping
843
+ the same `Logger`, levels, `JSONFormatter`, and plugin pipeline
844
+ (`ContextPlugin`/`RedactPlugin`/`PIIRedactPlugin`/`SamplingPlugin`), plus
845
+ `ConsoleTransport` and `BeaconTransport`. `FileTransport`, `HTTPTransport`,
846
+ every SQL/NoSQL/queue/cloud-native transport, and the LangChain adapters
847
+ are absent from this entry's module graph entirely — not tree-shaken,
848
+ simply never imported — so this bundle never pulls in a Node built-in.
849
+
850
+ ```ts
851
+ import { BeaconTransport, ConsoleTransport, Logger } from "logquill/browser";
852
+
853
+ const logger = new Logger("app", {
854
+ transports: [
855
+ new ConsoleTransport(),
856
+ new BeaconTransport("https://logs.example.com/ingest", { batchSize: 20 }),
857
+ ],
858
+ });
859
+
860
+ logger.info("page loaded", { path: location.pathname });
861
+ window.addEventListener("pagehide", () => logger.close()); // flushes the pending beacon batch
862
+ ```
863
+
864
+ `BeaconTransport` batches formatted records and sends them via
865
+ `navigator.sendBeacon`, which — unlike `fetch` — can complete even after
866
+ the page that queued it starts unloading; it falls back to a `keepalive`
867
+ `fetch` where `sendBeacon` isn't available (a worker, an older browser).
868
+ Keep `batchSize` small — `sendBeacon` payloads are capped (64KB in most
869
+ browsers).
870
+
871
+ One behavioral difference from the Node build: `Logger.span()`'s
872
+ `parentSpanId` nesting is backed by a plain stack here instead of
873
+ `AsyncLocalStorage` (which browsers don't have), so two spans on the same
874
+ `Logger` running concurrently across an `await` can interleave and stamp
875
+ the wrong `parentSpanId` — fine for the common case of one span in flight
876
+ at a time.
877
+
878
+ ## API reference
879
+
880
+ Every exported class and function carries a TSDoc comment; the full
881
+ reference, generated from those comments with
882
+ [TypeDoc](https://typedoc.org), is published at
883
+ [nikhilvdev.github.io/logquill-js](https://nikhilvdev.github.io/logquill-js/)
884
+ and rebuilt on every push to `main`. To build it locally:
885
+
886
+ ```sh
887
+ npm run docs # writes static HTML to site/
888
+ ```
889
+
505
890
  ## Development
506
891
 
507
892
  ```sh
@@ -510,6 +895,7 @@ npm run build # dist/index.{mjs,cjs,d.ts} via tsup
510
895
  npm run lint # eslint
511
896
  npm run typecheck # tsc --noEmit
512
897
  npm run coverage # vitest run --coverage
898
+ npm run docs # typedoc -> site/
513
899
  ```
514
900
 
515
901
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the PR workflow, the