diff --git a/apps/landing/public/screenshots/guides/loguru-01-logs.webp b/apps/landing/public/screenshots/guides/loguru-01-logs.webp new file mode 100644 index 0000000000..cd5624fe54 Binary files /dev/null and b/apps/landing/public/screenshots/guides/loguru-01-logs.webp differ diff --git a/apps/landing/public/screenshots/guides/loguru-02-log-detail.webp b/apps/landing/public/screenshots/guides/loguru-02-log-detail.webp new file mode 100644 index 0000000000..c9abb8986e Binary files /dev/null and b/apps/landing/public/screenshots/guides/loguru-02-log-detail.webp differ diff --git a/apps/landing/src/pages/guides/index.astro b/apps/landing/src/pages/guides/index.astro index af0afe574f..d4dcda146a 100644 --- a/apps/landing/src/pages/guides/index.astro +++ b/apps/landing/src/pages/guides/index.astro @@ -63,6 +63,13 @@ const coreGuides = [ description: "The DPS rate card, cost per host and per GiB, what OpenTelemetry data costs, and a worked monthly bill for a small fleet.", read: "9 min", }, + { + href: "/guides/loguru", + label: "Logging", + title: "Loguru for Python", + description: "Start logging with Loguru, set the level, write files and JSON, add context, then send its logs to OpenTelemetry.", + read: "7 min", + }, ]; const investigationGuides = [ diff --git a/apps/landing/src/pages/guides/loguru.astro b/apps/landing/src/pages/guides/loguru.astro new file mode 100644 index 0000000000..d90e1c4d4a --- /dev/null +++ b/apps/landing/src/pages/guides/loguru.astro @@ -0,0 +1,866 @@ +--- +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"; +import { CONTRIBUTORS } from "../../lib/changelog-meta"; +import { highlight } from "../../lib/guide-highlight"; + +const pageTitle = "Loguru for Python: setup, JSON and OpenTelemetry | Maple"; +const pageDescription = + "How to use Loguru in Python: install it, set the log level, write files and JSON, add context, and send Loguru logs to OpenTelemetry."; +const published = "2026-10-19"; +const updatedLabel = "October 22, 2026"; +const author = CONTRIBUTORS.jeremyfunk; +const authorUrl = `https://github.com/${author.github}`; + +const toc = [ + { depth: 2, slug: "get-started", text: "Get started" }, + { depth: 2, slug: "configure", text: "Levels, files and formats" }, + { depth: 3, slug: "set-level", text: "Set the log level" }, + { depth: 3, slug: "levels", text: "Loguru levels" }, + { depth: 3, slug: "files", text: "Write to a file" }, + { depth: 3, slug: "format", text: "Change the format" }, + { depth: 2, slug: "structured", text: "JSON logs and context" }, + { depth: 3, slug: "json", text: "JSON output" }, + { depth: 3, slug: "context", text: "bind and contextualize" }, + { depth: 2, slug: "exceptions", text: "Exceptions" }, + { depth: 2, slug: "stdlib", text: "Loguru and logging" }, + { depth: 3, slug: "intercept", text: "Library logs" }, + { depth: 3, slug: "comparison", text: "Loguru vs logging" }, + { depth: 2, slug: "opentelemetry", text: "Send logs to OpenTelemetry" }, + { depth: 3, slug: "otel-setup", text: "Install and configure" }, + { depth: 3, slug: "otel-run", text: "Run it" }, + { depth: 3, slug: "maple", text: "See the logs in Maple" }, +]; +const sectionNav = toc.filter((h) => h.depth === 2); + +// One color per system a log line passes through, used by the badges, the +// diagram and the code highlighting. +const systems = { + loguru: { name: "Loguru", color: "var(--chart-1)" }, + stdlib: { name: "logging", color: "var(--chart-2)" }, + otel: { name: "OpenTelemetry", color: "var(--chart-5)" }, +} as const; + +const levels = [ + { name: "TRACE", no: 5, method: "logger.trace()" }, + { name: "DEBUG", no: 10, method: "logger.debug()" }, + { name: "INFO", no: 20, method: "logger.info()" }, + { name: "SUCCESS", no: 25, method: "logger.success()" }, + { name: "WARNING", no: 30, method: "logger.warning()" }, + { name: "ERROR", no: 40, method: "logger.error()" }, + { name: "CRITICAL", no: 50, method: "logger.critical()" }, +]; + +const installCommand = "pip install loguru"; + +const quickstartCode = String.raw`from loguru import logger + +logger.debug("Starting worker") +logger.info("Processed {} orders in {:.1f}s", 42, 1.234) +logger.warning("Disk usage at {percent}%", percent=91)`; + +const quickstartOutput = String.raw`2026-10-22 20:37:59.500 | DEBUG | __main__::3 - Starting worker +2026-10-22 20:37:59.500 | INFO | __main__::4 - Processed 42 orders in 1.2s +2026-10-22 20:37:59.500 | WARNING | __main__::5 - Disk usage at 91%`; + +const anatomy = [ + ["2026-10-22 20:37:59.500", "{time}", "When it was logged"], + ["INFO", "{level}", "How severe"], + ["__main__::4", "{name}:{function}:{line}", "Where in your code"], + ["Processed 42 orders", "{message}", "Your text"], +]; + +const setLevelCode = String.raw`import sys +from loguru import logger + +logger.remove() # remove the default terminal sink +logger.add(sys.stderr, level="INFO") # add it back, INFO and above + +logger.debug("Hidden") +logger.info("Shown")`; + +const setLevelOutput = String.raw`2026-10-22 20:37:59.561 | INFO | __main__::8 - Shown`; + +const customLevelCode = String.raw`from loguru import logger + +logger.level("AUDIT", no=35, color="") +logger.log("AUDIT", "Role changed for user {}", 1042)`; + +const customLevelOutput = String.raw`2026-10-22 20:37:59.616 | AUDIT | __main__::4 - Role changed for user 1042`; + +const fileCode = String.raw`from loguru import logger + +logger.add( + "logs/app.log", + rotation="100 MB", # start a new file once this one reaches 100 MB + retention="14 days", # delete rotated files older than 14 days + compression="gz", # gzip each rotated file +) +logger.info("Written to the terminal and to logs/app.log")`; + +const formatCode = String.raw`import sys +from loguru import logger + +logger.remove() +logger.add(sys.stderr, format="{time:HH:mm:ss} | {level: <8} | {message}") +logger.info("User signed in")`; + +const formatOutput = String.raw`20:37:59 | INFO | User signed in`; + +const serializeCode = String.raw`import sys +from loguru import logger + +logger.remove() +logger.add(sys.stdout, serialize=True) +logger.info("Payment captured")`; + +const serializeOutput = String.raw`{"text": "2026-10-22 20:40:30.469 | INFO | __main__::6 - Payment captured\n", + "record": {"elapsed": {...}, "exception": null, "extra": {}, "file": {...}, + "function": "", "level": {"icon": "ℹ️", "name": "INFO", "no": 20}, + "line": 6, "message": "Payment captured", "module": "app", "name": "__main__", + "process": {...}, "thread": {...}, "time": {...}}}`; + +const contextCode = String.raw`import sys +from loguru import logger + +logger.remove() +logger.add(sys.stderr, format="{level: <7} | {message} | {extra}") + +log = logger.bind(user="ada") +log.info("Signed in") + + +def charge_card(): + logger.info("Charging card") + + +with logger.contextualize(request_id="req_9f2c"): + logger.info("Request started") + charge_card() + +logger.info("Request finished")`; + +const contextOutput = String.raw`INFO | Signed in | {'user': 'ada'} +INFO | Request started | {'request_id': 'req_9f2c'} +INFO | Charging card | {'request_id': 'req_9f2c'} +INFO | Request finished | {}`; + +const catchCode = String.raw`from loguru import logger + + +@logger.catch +def main(): + order = {"id": "ord_123", "total": 40, "quantity": 0} + print(order["total"] / order["quantity"]) + + +main()`; + +const catchOutput = String.raw`2026-10-22 18:38:39.486 | ERROR | __main__::10 - An error has been caught in function '', process 'MainProcess' (14), thread 'MainThread' (281473511032000): +Traceback (most recent call last): + +> File "/srv/app/main.py", line 10, in + main() + └ + + File "/srv/app/main.py", line 7, in main + print(order["total"] / order["quantity"]) + │ └ {'id': 'ord_123', 'total': 40, 'quantity': 0} + └ {'id': 'ord_123', 'total': 40, 'quantity': 0} + +ZeroDivisionError: division by zero`; + +const interceptCode = String.raw`# intercept.py +import inspect +import logging + +from loguru import logger + + +class InterceptHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Report the line that called logging, not a line inside the logging module. + frame, depth = inspect.currentframe(), 0 + while frame is not None and (depth == 0 or frame.f_code.co_filename == logging.__file__): + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)`; + +const interceptUsageCode = String.raw`# main.py +import logging + +import intercept # noqa: F401 +from loguru import logger + +logger.info("From Loguru") +logging.getLogger("payments.client").warning("Retrying %s", "ord_123")`; + +const interceptUsageOutput = String.raw`2026-10-22 20:42:11.692 | INFO | __main__::7 - From Loguru +2026-10-22 20:42:11.692 | WARNING | __main__::8 - Retrying ord_123`; + +const otelInstall = String.raw`pip install loguru \ + opentelemetry-sdk \ + opentelemetry-exporter-otlp-proto-http \ + opentelemetry-instrumentation-logging`; + +const telemetryCode = String.raw`# telemetry.py +import logging + +from loguru import logger +from opentelemetry import trace +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.logging.handler import LoggingHandler +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +ENDPOINT = "https://ingest.maple.dev" +HEADERS = {"authorization": "Bearer YOUR_INGEST_KEY"} +resource = Resource.create({"service.name": "checkout"}) + +# Traces: give each request a trace ID that its log lines can carry. +tracer_provider = TracerProvider(resource=resource) +tracer_provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{ENDPOINT}/v1/traces", headers=HEADERS)) +) +trace.set_tracer_provider(tracer_provider) + +# Logs: OpenTelemetry's LoggingHandler sends logging records to the backend. +logger_provider = LoggerProvider(resource=resource) +logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=f"{ENDPOINT}/v1/logs", headers=HEADERS)) +) +set_logger_provider(logger_provider) + +otel_logger = logging.getLogger("loguru.otel") +otel_logger.setLevel(logging.DEBUG) +otel_logger.propagate = False # send these records to the backend only +otel_logger.addHandler(LoggingHandler(logger_provider=logger_provider, log_code_attributes=True)) + + +# The bridge: a Loguru sink that turns each line into a logging record. +def otel_sink(message): + record = message.record + log_record = otel_logger.makeRecord( + record["name"], + record["level"].no, + record["file"].path, + record["line"], + record["message"], + (), + record["exception"], + func=record["function"], + extra=record["extra"], + ) + log_record.levelname = record["level"].name + otel_logger.handle(log_record) + + +logger.add( + otel_sink, + level="INFO", + # Don't send the SDK's own log lines back through the exporter. + filter=lambda record: not record["name"].startswith("opentelemetry"), +)`; + +const otelAppCode = String.raw`# app.py +import telemetry # noqa: F401 +from loguru import logger +from opentelemetry import trace + +tracer = trace.get_tracer("checkout") + +with tracer.start_as_current_span("POST /checkout"): + logger.info("Checkout started") + logger.bind(order_id="ord_123").info("Charging card") + logger.warning("Retrying webhook") + logger.success("Payment captured")`; + +const otelRun = String.raw`$ python app.py +2026-10-22 21:29:31.448 | INFO | __main__::9 - Checkout started +2026-10-22 21:29:31.448 | INFO | __main__::10 - Charging card +2026-10-22 21:29:31.448 | WARNING | __main__::11 - Retrying webhook +2026-10-22 21:29:31.448 | SUCCESS | __main__::12 - Payment captured`; + +const comparison = [ + ["First log line", "from loguru import logger", "basicConfig(), then getLogger(__name__)"], + ["Rotating log files", "rotation and retention on add()", "RotatingFileHandler"], + ["JSON output", "serialize=True", "A custom Formatter or a third-party package"], + ["Context fields", "bind(), contextualize()", "extra= on each call, or a LoggerAdapter"], + ["Used by libraries", "Rarely", "Nearly all of them"], +]; + +const faqs = [ + { + question: "What is Loguru in Python?", + answer: + "Loguru is a logging library for Python that works as soon as you import it. You call from loguru import logger and start logging, and it prints readable, colored lines to your terminal. Files, JSON output and other destinations are added with logger.add().", + }, + { + question: "Is Loguru free?", + answer: "Yes. Loguru is open source under the MIT license and installs from PyPI with pip install loguru.", + }, + { + question: "How do I set the log level in Loguru?", + answer: + "Remove the default sink and add it back with a level: logger.remove(), then logger.add(sys.stderr, level=\"INFO\"). To change it without editing code, set the environment variable LOGURU_LEVEL=INFO before starting the app.", + }, + { + question: "What are the Loguru levels?", + answer: + "TRACE (5), DEBUG (10), INFO (20), SUCCESS (25), WARNING (30), ERROR (40) and CRITICAL (50). They match the standard logging module's numbers, plus TRACE and SUCCESS. logger.level(\"NAME\", no=35) adds your own.", + }, + { + question: "Should I use Loguru or the standard logging module?", + answer: + "Loguru takes less setup in an application: files, JSON and context are one argument each. Libraries should log with the standard logging module so the application decides where the lines go. An application on Loguru can forward those library logs with an InterceptHandler.", + }, + { + question: "How do I send Loguru logs to OpenTelemetry?", + answer: + "The OpenTelemetry Python SDK collects logs from the standard logging module. Add a Loguru sink that turns each line into a logging record and hands it to a logger with OpenTelemetry's LoggingHandler attached. Lines logged during a request then carry its trace ID.", + }, + { + question: "Why do I get ModuleNotFoundError: No module named 'loguru'?", + answer: + "Loguru is installed for a different Python than the one running your code, usually another virtual environment. Install it with the same interpreter that runs the app: python -m pip install loguru.", + }, +]; + +const siteUrl = Astro.site?.toString().replace(/\/$/, "") ?? "https://maple.dev"; +const articleJsonLd = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: "Loguru for Python", + description: pageDescription, + datePublished: published, + dateModified: "2026-10-22", + author: { "@type": "Person", name: author.name, url: authorUrl }, + publisher: { + "@type": "Organization", + name: "Maple", + logo: { "@type": "ImageObject", url: `${siteUrl}/logo192.png` }, + }, + mainEntityOfPage: `${siteUrl}/guides/loguru`, +}; +--- + + + + + + diff --git a/apps/landing/src/pages/llms.txt.ts b/apps/landing/src/pages/llms.txt.ts index f391b0612e..f87719b4a7 100644 --- a/apps/landing/src/pages/llms.txt.ts +++ b/apps/landing/src/pages/llms.txt.ts @@ -117,6 +117,7 @@ export const GET: APIRoute = ({ site }) => { `- [What is OpenTelemetry?](${url("/opentelemetry")})`, `- [Best open-source observability tools](${url("/best-open-source-observability-tools")})`, `- [Dynatrace pricing, explained](${url("/guides/dynatrace-pricing")})`, + `- [Loguru for Python](${url("/guides/loguru")})`, `- [Python logging levels and how to set them](${url("/guides/python-logging-levels")})`, `- [Incident response, step by step](${url("/guides/incident-response")})`, "",