All notable changes to this project are documented in this file.
- Phase 8, CLI, complete:
logquill tail <file> [--level=] [--json] [-f/--follow] [-n/--lines]— alogquillconsole-script for tailing a JSONL log file in local dev. Human-readable output by default, colorized by level to matchConsoleTransport;--jsonprints each matching record as a raw JSON line instead.--levelfilters to that level and above;-nlimits to the last N matching records;-f/--followkeeps polling the file for newly appended records, for atail -f-style live view. A line that isn't valid JSON, or isn't a JSON object, is skipped with a warning on stderr instead of aborting the whole tail.
- Phase 7, advanced context & stdlib bridge, complete:
bind_context(**values)— acontextvars-based context manager that mergesvaluesinto everyLoggercall underneath it, through any method and any number of function calls deep, without threading them through each signature by hand. Isolated per thread/asyncio task; nested blocks merge, with the innermost value winning on key collision (an explicit call-sitemetavalue still wins over anything bound this way).current_context()reads the merged dict directly.exc_info=on everyLoggermethod (logger.error("failed", exc_info=e)) — accepts the same shapes stdlibloggingdoes (an exception instance,Truefor the exception currently being handled, or an explicit(type, value, traceback)tuple), formats a traceback intometa["stack"], and is never kept inmetaas the raw exception object, since that isn't serializable.format_exc_info()is exported directly for anything that wants the same formatting standalone.LogQuillHandler— alogging.Handlersubclass that bridges stdlibloggingcalls (including from third-party libraries) into a LogQuillLogger, so they flow through the same transports and plugin pipeline as a native.info()/.error()/... call.extra=fields land inmeta; an attachedexc_infois formatted intometa["stack"]the same way theLogger's ownexc_info=kwarg is. Level filtering still applies on top of whatever the stdlib logger/handler's own level is set to.RateLimitPlugin(max_records, per_seconds)— drops records once a key (by default(logger, level), or a customkey_func) exceedsmax_recordswithin a rolling per-key window, to cap a noisy loop without silencing the logger's other messages. Bounded bymax_keysdistinct keys tracked at once, evicting the least-recently-seen key's window to make room for a new one.
- Phase 6, async worker, shutdown & serverless safety, complete:
AsyncWorker— a bounded, in-memory queue backed by a single daemon thread, with a configurablebackpressurepolicy for what happens once that bound is hit under a sustained burst:drop_oldest(default, evicts the oldest queued item),drop_newest(discards the item that just overflowed the queue), orblock(the submitting thread waits for space instead of dropping anything). Either drop policy logs at most one warning per minute while actively dropping, not one per drop.Logger(async_dispatch=True, max_queue_size=10_000, backpressure= "drop_oldest")— moves each record's transport writes andafter_logplugin hooks onto that background thread, so.info()/.error()/... return without waiting on a transport's I/O;before_loghooks still run synchronously, since a later hook or transport needs to see their result in order.Logger.child()shares its parent's worker rather than starting a second background thread.Logger.flush(timeout=None)/await Logger.flush_async(timeout=None)— drain any queued records and flush each transport's own internal buffer (Transport.flush(), a new no-op-by-default hook; already matched byBatchingTransport's existing buffered-batch flush) without closing anything, so the logger stays usable right after.Logger.close (timeout=5.0)now drains the queue (up totimeout) before closing every transport.with_lambda(logger_or_loggers, timeout=5.0)— wraps a handler soflush()/flush_async()runs before the handler's result or exception reaches the caller, covering both sync andasync defhandlers. Flushes rather than closes, since a warm serverless container reuses the sameLogger/transports on its next invocation.with_cloud_functionandwith_azure_functionare the same decorator under a name that reads naturally at each platform's own handler definition — the flush-before-return behavior needed is identical across all three.load_config/logger_from_file/logger_from_envaccept the new"async_dispatch"/"max_queue_size"/"backpressure"config keys, mapped straight onto the matchingLoggerconstructor arguments.
LangGraphAdapter(pip install logquill[langgraph]) — corrects an overstatement in the 0.4.0 entry below: LangGraph nodes run as ordinary LangChainRunnables, soLangChainAdapteralone already captures node execution, but LangGraph also has its own checkpoint lifecycle —on_interrupt/on_resume, fired when a graph pauses on aninterrupt()call (e.g. for human review) and later resumes from a persisted checkpoint — that LangGraph dispatches only to handlers that are instances of its ownGraphCallbackHandler; a plainBaseCallbackHandlersubclass (allLangChainAdapteris) never receives them.LangGraphAdapterisLangChainAdapterplus those two, mapped to.observation ("graph_interrupted", ...)/.action("graph_resumed", ...)carryingcheckpoint_id/status/checkpoint_ns/pendingInterruptpayloads, with the event's ownrun_idasparent_span_id.pip install logquill[langgraph]pulls in a compatiblelangchain-coretransitively;langgraphis never imported unlesslogquill.adapters.langgraphis imported explicitly.
- Closed three gaps found auditing Phases 1–3 against their own written
exit criteria (each had been marked "shipped" despite this):
- Phase 1: config loading from file/env.
load_config(dict),logger_from_file(path)(JSON built in; YAML via the optionalpip install logquill[yaml]), andlogger_from_env(prefix= "LOGQUILL_")build aLoggerfrom{"name", "level", "transports": [{"type"|"class", "options"}], "plugins": [...]}. A small built-in"type"registry covers the zero-dependency transports/plugins; anything else (every cloud/SQL/NoSQL/queue transport, the alerting plugins, framework adapters, your own subclass) goes through"class"— a fully-qualified dotted path, resolved the same waylogging. config.dictConfigresolves one.{prefix}LEVELin the environment always overrides a config file's level. - Phase 2:
FileTransport(encrypt_key=...)encrypts each line withcryptography.fernet.Fernetbefore writing — worth doing here specifically because cloud transports typically already encrypt server-side, but a local log file on disk usually doesn't. Optionalpip install logquill[crypto], imported lazily. - Phase 3:
SyslogTransport— RFC 5424 messages over UDP (default) or TCP, stdlibsocketonly, no dependency. Not a batching transport, unlike the HTTP-API cloud transports: syslog is one-datagram/one- message-per-call.
- Phase 1: config loading from file/env.
- Fixed a pre-existing crash the plugin-pipeline hypothesis property test
caught during this audit, unrelated to the three gaps above: any of
Logger's message-taking methods (.info(),.error(),.action(), ...) raisedTypeError: got multiple values for argument 'message'if the caller's**metahappened to contain a key literally named"message"(and.child()/.span()had the same issue with"name") — exactly the kind of caller-crashing bug those hypothesis tests exist to catch.message/nameare now positional-only on every affected method, so ameta/fixed_metakey with that exact name now flows through as ordinary meta instead of colliding. - Phase 5, trace correlation & agentic tracing, complete:
Logger.child(name, **fixed_meta)— a namespaced logger sharing the parent's transports, with its own plugin pipeline and optional fixed context injected into every record..thought()/.action()/.observation()/.decision()—.info()withmeta.kindpre-set, for tagging agent reasoning steps.Logger.span(name), used aswith agent_log.span("call_llm"):— emits one record on exit carryingmeta.span_id/meta.duration_ms; every record logged inside the block (through any method) is automatically stamped withmeta.parent_span_id, so a full run reconstructs its exact nesting by sorting onspan_id/parent_span_id. Still emits its record, atERRORwithmeta.errorset, if the block raises — the exception itself propagates unchanged.RunPlugin— stampsmeta.run_id(generated if not given) and an incrementingmeta.step; one instance scopes one run, so concurrent runs never share a counter.TraceContextPlugin— stampsmeta.trace_idfor cross-service correlation, distinct fromrun_id. Resolves an active OpenTelemetry span's trace id first (best-effort, lazy import), then a W3Ctraceparent/AWS X-Ray/GCP trace header propagated via the newset_traceparent()/reset_traceparent()(acontextvars-based per-thread/asyncio-task mechanism), and generates a fresh id only if neither is available.LogQuillAdapterbase class +LangChainAdapter(pip install logquill[langchain]) — maps LangChain'sBaseCallbackHandlerevents onto the calls above; covers LangGraph for free, since it shares LangChain's callback system. LangChain's ownrun_id/parent_run_idare written directly ontometa.span_id/meta.parent_span_id.langchain-coreis never imported unlesslogquill.adapters.langchainis imported explicitly.
CrewAIAdapter(pip install logquill[crewai]) — a secondLogQuillAdapterimplementation, ahead of the phase schedule (CrewAI was listed as a Phase 5 follow-on, not required for that phase). Listens on CrewAI's own event bus (BaseEventListener) rather than a single callback handler; a crew kickoff and each task open/close a.span(), while agent execution, tool usage, and LLM calls become.action()/.observation()/.error()pairs withduration_ms. Correlation reads directly off CrewAI's ownevent.parent_event_id/event.started_event_id(populated by CrewAI's owncontextvars-backed scope stack) rather than tracking anything independently — the same field-renaming approachLangChainAdaptertakes with LangChain'srun_id/parent_run_id.crewaiis never imported unlesslogquill.adapters.crewaiis imported explicitly.LlamaIndexAdapter(pip install logquill[llamaindex]) — a thirdLogQuillAdapterimplementation. LlamaIndex's own instrumentation module splits into two cooperating registrations on a shared dispatcher, so this adapter holds one of each rather than being a handler itself: a span handler for LlamaIndex's own method-level calls (query(),chat(),retrieve(), ...), each becoming aspan_id/duration_msrecord withparent_span_idset for a nested call; and an event handler for named events fired within those calls, classified generically by class-name suffix (*StartEvent->.action(),*EndEvent->.observation(),*ErrorEvent->.error()) rather than enumerated one by one, so a new LlamaIndex event type needs no adapter change to show up correctly.llama-index-coreis never imported unlesslogquill.adapters.llamaindexis imported explicitly.AutoGenAdapter(pip install logquill[autogen]) — a fourthLogQuillAdapterimplementation, rounding out every framework CLAUDE.md names as a Phase 5 follow-on. Architecturally different from the other three: (Microsoft) AutoGen's actual integration point is a stdliblogging.Handlerattached toautogen_core.EVENT_LOGGER_NAME, where model clients and tools log structured event objects (not strings), so the adapter is aHandlerwhoseemit()unpacks that object rather than a callback/event-bus registration. Each event becomes a flat.action()/.observation()/.error()record; unlike the other three adapters, AutoGen's structured events carry no call-levelspan_id/parent_span_id-equivalent (onlyagent_id), so there's no span tree to reconstruct here — documented as a real limitation, not glossed over. Coversautogen-core/autogen-agentchatonly — not AG2, which forked from AutoGen and, as of its 2026 rewrite, moved onto its own event-driven architecture sharing none of this (confirmed against its source: zero references toEVENT_LOGGER_NAME); unlike LangGraph sharing LangChain's callback system, this is a genuine divergence and AG2 would need its own adapter.autogen-coreis never imported unlesslogquill.adapters.autogenis imported explicitly.
-
Plugin pipeline, Phase 4 complete:
SamplingPlugingained tail-based elevation — withtransports=set, a record that would be dropped is buffered permeta["trace_id"](configurable viatrace_key) instead of discarded outright, and if any later record in that trace reacheselevate_at(defaultERROR), the whole trace — every buffered record plus everything after — ships, flushed straight totransports. Buffering is bounded bymax_buffered_recordsandmax_traces, oldest trace evicted first. Withouttransports, behavior is unchanged from plain rate-based sampling. -
Logger.use()(and theplugins=[...]constructor list) now accepts a plain function alongside aPlugininstance — wrapped internally as an anonymousPlugin(FunctionPlugin) — so a one-offbefore_log-style transform doesn't require subclassingPluginfirst. -
PIIRedactPlugin: regex-based PII redaction overmetavalues (emails, SSNs, credit-card numbers, phone numbers), recursing through nested dicts/lists/tuples and matching regardless of which key holds the value — complementsRedactPlugin's exact-key matching. Depth- and cycle-bounded, so a circular reference or pathologically deep structure can't hang or crash the caller. An opt-inuse_presidio=Truemode (pip install logquill[presidio]) routes values through Microsoft Presidio's analyzer/anonymizer instead, for ML-based detection; Presidio is imported lazily and stays a real, non-default dependency. -
TamperEvidentPlugin: hash-chains every record (meta.hashover the record's own content plus the previous record'smeta.hash, stored asmeta.prev_hash), so editing, removing, or reordering a line in a written log breaks the chain from that point on. Ships with a staticTamperEvidentPlugin.verify_chain(records)to check a log after the fact. Opt-in — hashing every record has a real, measurable CPU cost. -
AlertingPluginbase class +SlackAlertPlugin,PagerDutyAlertPlugin, andEmailAlertPlugin: fires on ERROR/FATAL (or any configurablethreshold), with the actual send always running on a background thread so a slow or unreachable destination can never block the log call that triggered it. Repeated identical errors (same level + logger + message by default, or a customdedupe_key) withindedupe_window_secondscollapse into one follow-up alert carrying an occurrence count instead of spamming the destination once per record.send_alertfailures are caught and routed to the plugin's ownon_error, same as any other plugin hook. Tracking is bounded tomax_tracked_keysconcurrent dedupe windows — alerting degrades under extreme cardinality, logging itself never does. All three concrete plugins use only the stdlib (urllib,smtplib) — no new required dependency. -
Fixed a pre-existing gap surfaced by a new property-based test (see below):
Logger's per-transport dispatch had no error handling, so a transport that failed to format or write a given record (e.g.JSONFormatteron ametavalue containing a circular reference) would propagate the exception straight to the caller. Now caught and logged via the samelogging.getLogger("logquill")channelBatchingTransportalready uses, per transport, so one broken transport can't crash the caller or stop other attached transports from receiving the record. -
Added a
hypothesis-based property test (newdevdependency) that drives the plugin pipeline (ContextPlugin,RedactPlugin,PIIRedactPlugin,TamperEvidentPlugin) with adversarialmeta— deeply nested structures, unusual scalar types, non-JSON-serializable values, and circular references — asserting the pipeline never crashes the caller, only ever fails closed. -
New transports: SQL (
BaseSQLTransport+SQLiteTransport,PostgresTransport,MySQLTransport), NoSQL (MongoDBTransport,DynamoDBTransport,RedisTransport), message queues (BaseQueueTransport+KafkaTransport,RabbitMQTransport,SQSTransport,PubSubTransport), and cloud-native sinks (CloudWatchTransport,CloudLoggingTransport,AppInsightsTransport,DatadogTransport,ElasticsearchTransport,NewRelicTransport) — full parity withlogquill-js0.2.0. All of it sits on a new sharedBatchingTransportbase that bounds its buffer by both record count and estimated byte size, swaps the buffer out before sending so a synchronous re-entrant flush can't double-send, and catches a failing send rather than propagating it to the caller (logged via Python's stdliblogging.getLogger("logquill")) — a slow or down sink can't crash the process. Every optional backend driver (psycopg2-binary,pymysql,pymongo,boto3,redis,kafka-python,pika,google-cloud-pubsub,google-cloud-logging) is a lazy, injectable dependency behind a newpyproject.tomlextra (postgres,mysql,mongodb,redis,kafka,rabbitmq,pubsub,gcp-logging, and a sharedawsextra for CloudWatch/DynamoDB/SQS, all boto3-backed); a missing driver raises an actionableImportErrorrather than a cryptic one, and every test injects a hand-written fake instead of requiring a live service.SQLiteTransportneeds no extra at all (stdlibsqlite3).Two deliberate departures from
logquill-js's implementation, same outward behavior:AppInsightsTransportposts to Application Insights' public ingestion endpoint via stdliburllibinstead of an Azure SDK dependency, andSQSTransportdispatches its 10-message chunks sequentially rather than concurrently, since this project's dispatch is still fully synchronous end to end (true concurrency arrives once a non-blocking async worker exists).SyslogTransportisn't included here either, matchinglogquill-js0.2.0, which didn't ship it; it's a shared follow-up for both packages, not a Python-only gap.Also restructured
logquill/transport.py,console_transport.py,file_transport.py, andhttp_transport.pyinto a newlogquill/transports/subpackage (withsql/,nosql/,queue/, andcloud/subpackages) to hold the 17 new transports — a pure move, the publicfrom logquill import ...surface is unchanged. -
Plugin pipeline:
Pluginbase (before_log/after_log/on_error, all optional to override),ContextPlugin(merges fixed context intometa),RedactPlugin(replaces sensitivemetavalues by key, case- insensitive), andSamplingPlugin(probabilistically drops records).Loggernow acceptsplugins=[...]and gained.use(plugin)to register one and chain. A plugin hook that raises is caught, routed to that same plugin'son_error, and the pipeline continues — a broken plugin can't crash logging, verified by test. -
Added
.github/dependabot.yml: weekly version updates forpipdependencies and GitHub Actions. -
Added GitHub issue templates:
.github/ISSUE_TEMPLATE/bug_report.yml,feature_request.yml, and aconfig.ymlthat points security reports at private vulnerability reporting instead of a public issue. -
Added
.github/SECURITY.md: supported-versions policy and instructions to report vulnerabilities via GitHub's private vulnerability reporting instead of public issues. Linked from the README. -
Transports:
Transportbase (format/write/close),ConsoleTransport(colorized, ERROR/FATAL to stderr),FileTransport(size-based rotation), andHTTPTransport(batched, newline-delimited JSON over stdliburllib, with an injectablesenderfor tests or alternate backends).Loggernow acceptstransports=[...]and dispatches each record to them synchronously, and gained.close()to close all attached transports. Dispatch is still synchronous — a non-blocking queue/async path isn't implemented yet. Also addedCollectingTransport, an in-memory transport for tests. -
Added
CODE_OF_CONDUCT.md(Contributor Covenant v2.1),.github/CODEOWNERS,.github/PULL_REQUEST_TEMPLATE.md, andCONTRIBUTING.mddocumenting the PR workflow (branch naming, scoping, review/CI requirements, squash-merge). -
Core API:
Level(TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching logquill-js's numeric weights),parse_level(), theLogRecordshape,Loggerwith.trace()/.debug()/.info()/.warn()/.error()/.fatal()and.set_level(), and aFormatterprotocol with aJSONFormatterimplementation. Log calls return the record dict (orNonewhen filtered by level) — no transports or dispatch yet. -
Repo scaffold:
pyproject.toml, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow. -
Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an
Issuesproject URL, and fixed theHomepage/Repository/ChangelogURLs to point at the actualnikhilvdev/logquill-pythonGitHub repo instead of a stale placeholder org. -
Added a pepy.tech download-count badge to the README for tracking installs.