From 0b4aa6a3887fe19d7c7e2d962f4d9b5bd8094229 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Mon, 19 Oct 2026 19:17:07 +0200 Subject: [PATCH 1/6] feat(landing): Python logging levels guide Covers the standard levels, logger vs handler levels and propagation, setting the level from argparse, LOG_LEVEL and dictConfig, per-module levels, custom levels and getLevelNamesMapping, and how levels interact with the OpenTelemetry LoggingHandler. Fixes the Python instrumentation doc, which suggested basicConfig after the handler is attached, where it is a no-op. --- .../docs/guides/instrumentation-python.md | 2 +- apps/landing/src/pages/guides/index.astro | 7 + .../pages/guides/python-logging-levels.astro | 608 ++++++++++++++++++ apps/landing/src/pages/llms.txt.ts | 1 + 4 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 apps/landing/src/pages/guides/python-logging-levels.astro diff --git a/apps/landing/src/content/docs/guides/instrumentation-python.md b/apps/landing/src/content/docs/guides/instrumentation-python.md index cae9b38b05..8754b17921 100644 --- a/apps/landing/src/content/docs/guides/instrumentation-python.md +++ b/apps/landing/src/content/docs/guides/instrumentation-python.md @@ -236,7 +236,7 @@ import logging logging.getLogger(__name__).warning("Payment retry for order %s", order_id) ``` -The root logger's level still applies. Python defaults it to `WARNING`, so call `logging.basicConfig(level=logging.INFO)` or set the level yourself to send `INFO` records. +The root logger's level still applies. Python defaults it to `WARNING`, so call `logging.getLogger().setLevel(logging.INFO)` to send `INFO` records. `logging.basicConfig(level=...)` does nothing once the root logger has a handler, so after `import telemetry` it no longer sets the level. See [Python logging levels](/guides/python-logging-levels) for per-module and per-handler levels. ## Verify diff --git a/apps/landing/src/pages/guides/index.astro b/apps/landing/src/pages/guides/index.astro index 63b5f46b17..e60244841f 100644 --- a/apps/landing/src/pages/guides/index.astro +++ b/apps/landing/src/pages/guides/index.astro @@ -73,6 +73,13 @@ const investigationGuides = [ description: "Measure the right latency clock, isolate the slow requests, and trace a regression to the release and operation responsible.", read: "14 min", }, + { + href: "/guides/python-logging-levels", + label: "Python", + title: "Python logging levels and how to set them", + description: "The numeric value of each level, why debug logs don't print, and how to set the level from basicConfig, a --log-level flag, LOG_LEVEL or dictConfig.", + read: "11 min", + }, ]; const fieldNotes = (await getCollection("blog") as BlogPost[]) diff --git a/apps/landing/src/pages/guides/python-logging-levels.astro b/apps/landing/src/pages/guides/python-logging-levels.astro new file mode 100644 index 0000000000..6b9105d457 --- /dev/null +++ b/apps/landing/src/pages/guides/python-logging-levels.astro @@ -0,0 +1,608 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import Nav from "../../components/Nav.astro"; +import Footer from "../../components/Footer.astro"; +import BottomCTA from "../../components/BottomCTA.astro"; +import Breadcrumb from "../../components/Breadcrumb.astro"; +import FAQ from "../../components/FAQ.astro"; +import SeoHead from "../../components/SeoHead.astro"; +import TableOfContents from "../../components/TableOfContents.astro"; + +const pageTitle = "Python Logging Levels: How to Set the Log Level (CLI, Env, dictConfig)"; +const pageDescription = + "Python logging levels with their numeric values, why debug logs don't print, and how to set the log level with basicConfig, setLevel, argparse and LOG_LEVEL."; +const published = "2026-10-19"; +const updatedLabel = "October 19, 2026"; + +const toc = [ + ["levels", "The standard levels"], + ["debug-not-printing", "Why debug logs don't print"], + ["basicconfig", "Set the level with basicConfig"], + ["logger-vs-handler", "Logger level vs handler level"], + ["command-line", "Set the level from the command line"], + ["environment-variable", "Set the level from an environment variable"], + ["dictconfig", "dictConfig and per-module levels"], + ["custom-levels", "Custom levels"], + ["opentelemetry", "Levels when logs go to Maple"], +] as const; +const tocHeadings = toc.map(([slug, text]) => ({ depth: 2, slug, text })); + +const levels = [ + ["CRITICAL", "50", "The program may not be able to keep running: a required config file is missing, the database is unreachable at startup."], + ["ERROR", "40", "One operation failed: a request returned 500, a payment call raised. The process carries on."], + ["WARNING", "30", "Something unexpected happened but the work succeeded: a retry, a deprecated option, disk space running low."], + ["INFO", "20", "Normal milestones you want in production: service started, job finished, order placed."], + ["DEBUG", "10", "Detail for diagnosing a problem: payloads, cache hits and misses, branch decisions."], + ["NOTSET", "0", "Not a severity. On a logger it means \"ask my parent\"; on a handler it means \"accept everything\"."], +]; + +const noHandlerCode = `import logging + +logger = logging.getLogger("app") +logger.setLevel(logging.DEBUG) + +logger.debug("cache miss for user 42") # not printed +logger.warning("disk 91% full") # printed`; + +const basicConfigCode = `import logging + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", +) + +log = logging.getLogger(__name__) +log.debug("cache miss for user 42") # printed now`; + +const handlerLevelsCode = `import logging + +root = logging.getLogger() +root.setLevel(logging.DEBUG) # logger gate: let DEBUG through + +console = logging.StreamHandler() +console.setLevel(logging.WARNING) # terminal shows WARNING and above + +logfile = logging.FileHandler("app.log") +logfile.setLevel(logging.DEBUG) # file keeps everything + +root.addHandler(console) +root.addHandler(logfile) + +log = logging.getLogger("myapp") +log.debug("file only") +log.warning("file and terminal")`; + +const propagationCode = `import logging + +logging.basicConfig(level=logging.WARNING) + +logging.getLogger("app").info("not printed: app inherits WARNING from root") + +db = logging.getLogger("app.db") +db.setLevel(logging.DEBUG) +db.debug("printed, although root is WARNING")`; + +const argparseCode = `import argparse +import logging +import os + +parser = argparse.ArgumentParser() +parser.add_argument( + "--log-level", + default=os.environ.get("LOG_LEVEL", "WARNING"), + type=str.upper, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="minimum level to log (default: $LOG_LEVEL, else WARNING)", +) +args = parser.parse_args() + +logging.basicConfig( + level=args.log_level, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", +) +log = logging.getLogger(__name__) +log.debug("parsed arguments: %s", args) +log.info("starting")`; + +const argparseRun = `$ python app.py --log-level debug +2026-10-19 09:12:01,331 DEBUG __main__: parsed arguments: Namespace(log_level='DEBUG') +2026-10-19 09:12:01,331 INFO __main__: starting + +$ LOG_LEVEL=info python app.py +2026-10-19 09:12:04,870 INFO __main__: starting + +$ python app.py --log-level loud +usage: app.py [-h] [--log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL}] +app.py: error: argument --log-level: invalid choice: 'LOUD' (choose from DEBUG, INFO, WARNING, ERROR, CRITICAL)`; + +const verboseCode = `import argparse +import logging + +parser = argparse.ArgumentParser() +parser.add_argument("-v", "--verbose", action="count", default=0) +args = parser.parse_args() + +# no flag: WARNING, -v: INFO, -vv or more: DEBUG +logging.basicConfig(level=max(logging.DEBUG, logging.WARNING - 10 * args.verbose))`; + +const envCode = `import logging +import os + +logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper())`; + +const envRun = `$ LOG_LEVEL=debug python worker.py # DEBUG and above +$ python worker.py # INFO and above +$ LOG_LEVEL=loud python worker.py +Traceback (most recent call last): + ... +ValueError: Unknown level: 'LOUD'`; + +const dictConfigCode = `import logging +import logging.config +import os + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "plain": {"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s"}, + }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "plain"}, + }, + "root": {"level": os.environ.get("LOG_LEVEL", "INFO").upper(), "handlers": ["console"]}, + "loggers": { + "myapp.payments": {"level": "DEBUG"}, + "urllib3": {"level": "WARNING"}, + "botocore": {"level": "WARNING"}, + }, +} + +logging.config.dictConfig(LOGGING) + +logging.getLogger("myapp.payments").debug("printed: payments is set to DEBUG") +logging.getLogger("myapp.api").debug("not printed: inherits INFO from root") +logging.getLogger("urllib3.connectionpool").info("not printed: urllib3 is WARNING")`; + +const noisyCode = `for name in ("urllib3", "botocore", "httpx", "httpcore"): + logging.getLogger(name).setLevel(logging.WARNING)`; + +const customCode = `import logging + +TRACE = 5 +logging.addLevelName(TRACE, "TRACE") + +logging.basicConfig(level=TRACE) +logging.getLogger(__name__).log(TRACE, "entering parse_header")`; + +const mappingCode = `>>> import logging +>>> logging.getLevelNamesMapping() +{'CRITICAL': 50, 'FATAL': 50, 'ERROR': 40, 'WARN': 30, 'WARNING': 30, 'INFO': 20, 'DEBUG': 10, 'NOTSET': 0} +>>> "VERBOSE" in logging.getLevelNamesMapping() +False +>>> logging.getLevelName(20) +'INFO' +>>> logging.getLevelName("INFO") # works, but only for backward compatibility +20`; + +const otelCode = `# telemetry.py, after logger_provider is created as in the Python guide +root = logging.getLogger() +root.setLevel(logging.DEBUG) + +console = logging.StreamHandler() +console.setFormatter(logging.Formatter("%(levelname)-8s %(name)s: %(message)s")) +root.addHandler(console) + +root.addHandler(LoggingHandler(level=logging.INFO, logger_provider=logger_provider)) + +logging.getLogger("urllib3").setLevel(logging.WARNING)`; + +const faqs = [ + { + question: "What are the Python logging levels?", + answer: + "Python's logging module defines five levels: DEBUG (10), INFO (20), WARNING (30), ERROR (40) and CRITICAL (50). NOTSET (0) is not a severity; it tells a logger to use its parent's level. WARN and FATAL are aliases for WARNING and CRITICAL. A logger handles a record only when the record's level is at or above the logger's effective level.", + }, + { + question: "What is the default logging level in Python?", + answer: + "The root logger starts at WARNING. Loggers you create with logging.getLogger(name) start at NOTSET, so they inherit WARNING from the root until you set a level on them or on one of their parents. That default is why logger.info() and logger.debug() print nothing in a fresh script.", + }, + { + question: "Why doesn't my Python debug log print?", + answer: + "Usually because the root logger is still at its default WARNING level. Call logging.basicConfig(level=logging.DEBUG) at startup. If you set DEBUG on the logger and still see nothing, there is no handler: Python falls back to an internal last-resort handler whose own level is WARNING. If basicConfig has no effect, the root logger already has a handler, and basicConfig does nothing in that case unless you pass force=True.", + }, + { + question: "How do I set the Python logging level from the command line?", + answer: + "Add a --log-level option with argparse, normalise it with type=str.upper, restrict it with choices, and pass the result to logging.basicConfig(level=args.log_level). basicConfig accepts level names as strings. Python has no built-in command-line flag or environment variable for the log level, so the application has to read one itself.", + }, + { + question: "How do I set the logging level for one module?", + answer: + "Call logging.getLogger(\"module.name\").setLevel(...) for that logger, or add it under the loggers key in dictConfig. Logger names are dotted paths, so setting app.db also covers app.db.pool. The same technique quiets a noisy library: set urllib3 or botocore to WARNING while your own code stays at DEBUG.", + }, + { + question: "What is the difference between logger level and handler level?", + answer: + "The logger level decides whether a record is created and passed on at all. Each handler level then decides whether that handler outputs the record. A record must pass both. When a record propagates to a parent logger, only the parent's handlers check their levels; the parent logger's own level is not consulted.", + }, +]; + +const siteUrl = Astro.site?.toString().replace(/\/$/, "") ?? "https://maple.dev"; +const articleJsonLd = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: "Python logging levels and how to set the log level", + description: pageDescription, + datePublished: published, + dateModified: published, + author: { "@type": "Organization", name: "Maple" }, + publisher: { + "@type": "Organization", + name: "Maple", + logo: { "@type": "ImageObject", url: `${siteUrl}/logo192.png` }, + }, + mainEntityOfPage: `${siteUrl}/guides/python-logging-levels`, +}; +--- + + + + +