feat(logging): production-grade structured logs for Grafana/Loki (#13164)

* feat(logging): production-grade structured logs for Grafana/Loki

Make langflow and lfx log output viable for ingestion by Grafana/Loki and
other observability tools when run in JSON mode (LANGFLOW_LOG_ENV=container).

Core changes in src/lfx/src/lfx/log/logger.py:
- Preserve exceptions in JSON output via structlog.processors.ExceptionRenderer
  with ExceptionDictTransformer. Tracebacks now emit as a structured exception
  array (exc_type, exc_value, frames) instead of being dropped.
- show_locals defaults to OFF; opt-in via LANGFLOW_LOG_TRACE_LOCALS=true so
  frame locals can't leak API keys, env, or request bodies in shipped logs.
- Add service metadata (service / version / environment) from
  LANGFLOW_SERVICE_NAME / LANGFLOW_VERSION / LANGFLOW_ENVIRONMENT.
- Add logger name to every record so Grafana can filter by source.
- Add optional OpenTelemetry trace_id / span_id correlation. Import is
  resolved once at module load; runtime calls are wrapped so a flaky tracer
  SDK can never break logging.
- Add default-on PII redaction for password, token, api_key, authorization,
  cookie, etc. Walks nested dicts, lists, and tuples up to depth 4. Extra
  keys via LANGFLOW_LOG_REDACT_KEYS.
- Add per-logger level overrides via LANGFLOW_LOG_LEVELS="name=LEVEL,...".
  Malformed entries (typos like WARN instead of WARNING) raise UserWarning
  instead of silently dropping.
- Use ISO 8601 UTC timestamps.
- Install a stdlib InterceptHandler on the root logger in JSON modes so
  uvicorn, sqlalchemy, httpx, langchain, asyncio etc. emit a single unified
  JSON stream. Forwards exc_info and stack_info. emit() is wrapped to route
  any error through handleError so a malformed third-party log call cannot
  raise into the request path. Install is idempotent: re-running configure()
  updates the level instead of stacking handlers. Not installed in pretty
  mode so dev terminals don't get duplicate lines.
- Reset cached loggers at the start of configure() so modules that captured
  a logger before configure() ran pick up the new processor chain.
- Preserve the get_logger() name through PrintLogger so add_logger_name can
  attach it.

Tests in src/backend/tests/unit/test_logger.py:
- Cover structured tracebacks, PII redaction (top-level, nested, list,
  tuple, depth limit), logger name, stdlib intercept forwarding exc_info
  and stack_info, intercept idempotency, intercept-not-installed in pretty
  mode, malformed-args safety net, service info defaults and env overrides,
  malformed LANGFLOW_LOG_LEVELS warning, container_csv exception text,
  show_locals default off (verified by absence of the secret value, not
  just the key) and opt-in.

* docs(observability): Grafana + Loki reference stack and env-var docs

Adds a self-contained Loki + Promtail + Grafana docker-compose stack under
deploy/observability/grafana-loki/ with a pre-provisioned dashboard that
demonstrates the production logging features: structured tracebacks, PII
redaction, service/version/environment labels, and the stdlib intercept
path. Anyone running Langflow in JSON mode can point Promtail at their log
file and get a working board on first run.

Also documents the new env vars (LANGFLOW_SERVICE_NAME, LANGFLOW_VERSION,
LANGFLOW_ENVIRONMENT, LANGFLOW_LOG_LEVELS, LANGFLOW_LOG_REDACT_KEYS,
LANGFLOW_LOG_TRACE_LOCALS) in docs/docs/Develop/logging.mdx and adds a
new page docs/docs/Develop/observability-grafana-loki.mdx covering JSON
output shape, structured exceptions, stdlib routing, and OpenTelemetry
trace correlation. Registered in the Observability sidebar category.

* docs(observability): rename dashboard to 'Langflow Logs'

* docs(observability): fix broken link from logging guide to Grafana/Loki page

The logging guide linked to the new Grafana/Loki page with an absolute
path (/observability-grafana-loki). Since the page is new and only
exists in the next docs version, the absolute link resolved to a
non-existent root-version route and failed the Docusaurus broken-link
check. Use a version-aware relative .mdx link instead.

* docs(observability): clarify stdout requirement for unified JSON logs

The Grafana/Loki guide told users to set LANGFLOW_LOG_FILE, but the stdlib
intercept that routes uvicorn/sqlalchemy/httpx/langchain into the JSON stream
is only installed on the stdout path, so those library logs landed in the file
as plain text and the json parse stage could not label them. Point the guide at
stdout (redirected to the scraped file) and note the file-mode limitation.

Also document that the JSON format is platform-agnostic and works with any
JSON-ingesting backend including IBM Instana, whose OpenTelemetry-based Python
tracer (3.0+) correlates logs to traces via trace_id/span_id.

* fix(logging): render stdlib logs as redacted JSON in file mode

In JSON mode with LANGFLOW_LOG_FILE set, the stdlib intercept was skipped to
avoid a recursion loop, so third-party loggers (uvicorn, sqlalchemy, httpx,
asyncio) wrote plain text straight to the file. Those lines bypassed both JSON
rendering and PII redaction, so secrets in their structured fields landed in the
file verbatim and Loki could not parse or label them.

Route JSON file output through a structlog ProcessorFormatter on the rotating
handler: foreign stdlib records are enriched via foreign_pre_chain (ExtraAdder +
redaction) and rendered as JSON alongside application logs, while the
RotatingFileHandler keeps log rotation. The stdout path is unchanged. Also
forward stdlib extra fields through InterceptHandler so the stdout path redacts
them too, keeping both paths consistent.

Update the Grafana/Loki deploy guide to reflect that LANGFLOW_LOG_FILE now
produces a single redacted JSON stream.

* fix(logging): retrieval buffer captures the message text

add_serialized stored the rendered message under the 'message' key, but
SizedLogBuffer.write only read 'event'/'msg'/'text', so every entry returned by
the /logs and /logs-stream endpoints had an empty message. Read 'message' first
and keep the other keys as fallbacks for records written in other shapes.
This commit is contained in:
Gabriel Luiz Freitas Almeida
2026-06-01 17:23:31 -03:00
committed by GitHub
parent fb046c02df
commit e2d17a6d84
11 changed files with 1186 additions and 67 deletions

View File

@ -0,0 +1,69 @@
# Langflow on Grafana + Loki
Reference stack that ingests Langflow's structured JSON logs into [Loki](https://grafana.com/oss/loki/) and visualizes them with a pre-provisioned Grafana dashboard.
Use this as a starting point. The compose file, Promtail config, and dashboard JSON are independent of the rest of `deploy/` and can be lifted into any environment.
## What you get
- **Loki 3.2** on `:3100`
- **Promtail 3.2** scraping a directory of Langflow log files
- **Grafana 11.3** on `:3000` with the Loki datasource and the `Langflow Logs` dashboard already provisioned
## Prerequisites on the Langflow side
The dashboard expects Langflow to be running in JSON mode with service metadata set. At minimum:
```bash
LANGFLOW_LOG_ENV=container
LANGFLOW_LOG_FILE=/var/log/langflow/langflow.log
LANGFLOW_SERVICE_NAME=langflow
LANGFLOW_VERSION=1.10.0
LANGFLOW_ENVIRONMENT=production
```
In JSON mode the file is a single JSON stream: application logs and third-party stdlib loggers
(`uvicorn`, `sqlalchemy`, `httpx`, `langchain`) are all rendered as JSON and run through PII
redaction, so the `json` parse stage and the **Stdlib intercept routing** panel work against it
directly. If you'd rather not write a file, drop `LANGFLOW_LOG_FILE` and scrape the container's
stdout instead (same JSON, same labels).
See [Logs and observability](../../../docs/docs/Develop/observability-grafana-loki.mdx) for the full list of environment variables (per-logger overrides, extra PII redaction keys, trace correlation, etc.).
## Run
From this directory:
```bash
# Point Promtail at the directory containing your langflow*.log file(s).
# Defaults to ./logs/ if unset (useful for a quick local smoke test).
export LANGFLOW_LOG_DIR=/path/to/langflow/logs
docker compose up -d
```
Then open [http://localhost:3000/d/langflow-prod-logs](http://localhost:3000/d/langflow-prod-logs). Default credentials are `admin` / `admin` (override with `GF_ADMIN_USER` and `GF_ADMIN_PASSWORD`).
To stop:
```bash
docker compose down -v
```
## What each dashboard panel proves
| Panel | LogQL it runs |
|---|---|
| **PII leak count (must be 0)** | `sum(count_over_time({job="langflow"} \|~ "sk-do-not-leak\|hunter2\|Bearer xyz" [$__range]))` |
| **Errors with structured tracebacks** | `{job="langflow", level=~"error\|critical"} \|= "exception" \| json` |
| **Redaction proof** | `{job="langflow"} \|~ "\\*\\*\\*"` |
| **Stdlib intercept routing** | `{job="langflow", logger=~"uvicorn.*\|sqlalchemy.*\|httpx.*\|langchain.*"}` |
| **Service / environment / version coverage** | `sum by (service, environment, version) (count_over_time({job="langflow"}[$__range]))` |
| **Log rate by level** | `sum by (level) (rate({job="langflow"}[1m]))` |
| **Log rate by logger** | `topk(10, sum by (logger) (rate({job="langflow"}[1m])))` |
## Notes
- Promtail only promotes `level`, `service`, `environment`, `version`, `logger` to labels. High-cardinality fields (`user_id`, `flow_id`, `trace_id`) stay in the log body — query them with `| json` in LogQL.
- Replace Promtail with [Grafana Alloy](https://grafana.com/oss/alloy/) if you already standardize on it; the JSON parse stage maps 1:1.
- If your runtime ships logs through a different transport (Fluent Bit, Vector, OTLP), only the scrape side changes — the dashboard and label schema stay the same.

View File

@ -0,0 +1,41 @@
services:
loki:
image: grafana/loki:3.2.0
container_name: lf-loki
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3100/ready"]
interval: 5s
timeout: 3s
retries: 20
promtail:
image: grafana/promtail:3.2.0
container_name: lf-promtail
volumes:
- ./promtail/config.yml:/etc/promtail/config.yml
# Mount the directory that holds your langflow JSON log file.
# Override LANGFLOW_LOG_DIR to point at your real log location.
- ${LANGFLOW_LOG_DIR:-./logs}:/var/log/langflow:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
loki:
condition: service_healthy
grafana:
image: grafana/grafana:11.3.0
container_name: lf-grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=${GF_ADMIN_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-admin}
- GF_USERS_DEFAULT_THEME=dark
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- ./grafana/dashboards:/var/lib/grafana/dashboards
depends_on:
loki:
condition: service_healthy

View File

@ -0,0 +1,163 @@
{
"uid": "langflow-prod-logs",
"title": "Langflow Logs",
"description": "Structured logs from Langflow and lfx: tracebacks, PII redaction, service labels, stdlib intercept, trace correlation.",
"tags": ["langflow", "lfx", "structured-logs"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "5s",
"time": {
"from": "now-15m",
"to": "now"
},
"templating": {
"list": [
{
"name": "environment",
"type": "query",
"datasource": "Loki",
"query": "label_values({job=\"langflow\"}, environment)",
"current": {"text": "All", "value": "$__all"},
"includeAll": true,
"multi": true,
"refresh": 2
},
{
"name": "service",
"type": "query",
"datasource": "Loki",
"query": "label_values({job=\"langflow\"}, service)",
"current": {"text": "All", "value": "$__all"},
"includeAll": true,
"multi": true,
"refresh": 2
}
]
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Total log lines (window)",
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
"datasource": "Loki",
"targets": [
{"expr": "sum(count_over_time({job=\"langflow\", service=~\"$service\", environment=~\"$environment\"}[$__range]))", "refId": "A"}
],
"options": {"reduceOptions": {"calcs": ["lastNotNull"]}}
},
{
"id": 2,
"type": "stat",
"title": "Errors (window)",
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
"datasource": "Loki",
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}},
"targets": [
{"expr": "sum(count_over_time({job=\"langflow\", level=~\"error|critical\", service=~\"$service\", environment=~\"$environment\"}[$__range]))", "refId": "A"}
]
},
{
"id": 3,
"type": "stat",
"title": "Distinct loggers",
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"datasource": "Loki",
"targets": [
{"expr": "count(count by (logger)({job=\"langflow\", service=~\"$service\", environment=~\"$environment\"}))", "refId": "A"}
]
},
{
"id": 4,
"type": "stat",
"title": "PII leak count (must be 0)",
"description": "Counts log lines that contain literal sensitive values that should have been redacted. Anything non-zero means redaction failed.",
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"datasource": "Loki",
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}},
"targets": [
{"expr": "sum(count_over_time({job=\"langflow\"} |~ \"sk-do-not-leak|hunter2|Bearer xyz\"[$__range]))", "refId": "A"}
]
},
{
"id": 5,
"type": "timeseries",
"title": "Log rate by level",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
"datasource": "Loki",
"targets": [
{"expr": "sum by (level) (rate({job=\"langflow\", service=~\"$service\", environment=~\"$environment\"}[1m]))", "refId": "A", "legendFormat": "{{level}}"}
],
"options": {"legend": {"displayMode": "table", "placement": "right", "calcs": ["sum"]}}
},
{
"id": 6,
"type": "timeseries",
"title": "Log rate by logger (top 10)",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
"datasource": "Loki",
"targets": [
{"expr": "topk(10, sum by (logger) (rate({job=\"langflow\", service=~\"$service\", environment=~\"$environment\"}[1m])))", "refId": "A", "legendFormat": "{{logger}}"}
],
"options": {"legend": {"displayMode": "table", "placement": "right", "calcs": ["sum"]}}
},
{
"id": 7,
"type": "logs",
"title": "Structured tracebacks (errors with exception array)",
"description": "Every error in JSON mode now ships a structured exception array (exc_type, exc_value, frames) instead of being silently dropped. Expand a row to see the structure.",
"gridPos": {"h": 12, "w": 24, "x": 0, "y": 12},
"datasource": "Loki",
"targets": [
{"expr": "{job=\"langflow\", level=~\"error|critical\", service=~\"$service\", environment=~\"$environment\"} |= \"exception\" | json", "refId": "A"}
],
"options": {"showLabels": true, "showTime": true, "wrapLogMessage": true, "prettifyLogMessage": true, "dedupStrategy": "none"}
},
{
"id": 8,
"type": "logs",
"title": "PII redaction proof (look for *** sentinels, not the raw value)",
"description": "Sensitive keys (password, api_key, token, authorization, cookie) appear as *** before the JSON ever leaves the process.",
"gridPos": {"h": 10, "w": 12, "x": 0, "y": 24},
"datasource": "Loki",
"targets": [
{"expr": "{job=\"langflow\", service=~\"$service\"} |~ \"\\\\*\\\\*\\\\*\"", "refId": "A"}
],
"options": {"showLabels": false, "showTime": true, "wrapLogMessage": true, "prettifyLogMessage": true}
},
{
"id": 9,
"type": "logs",
"title": "Stdlib intercept: third-party loggers routed into JSON",
"description": "uvicorn, sqlalchemy, httpx, langchain, asyncio used to emit unstructured text. With LANGFLOW_LOG_ENV=container they come through the same JSON stream with logger=<name>.",
"gridPos": {"h": 10, "w": 12, "x": 12, "y": 24},
"datasource": "Loki",
"targets": [
{"expr": "{job=\"langflow\", logger=~\"uvicorn.*|sqlalchemy.*|httpx.*|langchain.*|third_party.*\", service=~\"$service\"}", "refId": "A"}
],
"options": {"showLabels": true, "showTime": true, "wrapLogMessage": true, "prettifyLogMessage": true}
},
{
"id": 10,
"type": "table",
"title": "Top error sources",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 34},
"datasource": "Loki",
"targets": [
{"expr": "topk(10, sum by (logger) (count_over_time({job=\"langflow\", level=~\"error|critical\"}[$__range])))", "refId": "A", "instant": true, "format": "table"}
]
},
{
"id": 11,
"type": "table",
"title": "Service / environment / version coverage",
"description": "Confirms every record carries the service metadata Grafana dashboards key off of.",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 34},
"datasource": "Loki",
"targets": [
{"expr": "sum by (service, environment, version) (count_over_time({job=\"langflow\"}[$__range]))", "refId": "A", "instant": true, "format": "table"}
]
}
]
}

View File

@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: langflow
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View File

@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
editable: false
jsonData:
maxLines: 1000

View File

@ -0,0 +1,35 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: langflow
static_configs:
- targets:
- localhost
labels:
job: langflow
__path__: /var/log/langflow/*.log
pipeline_stages:
# Parse the structlog JSON line and promote useful fields to labels.
# Keep this set small; high-cardinality fields (user_id, flow_id,
# trace_id) should stay in the log body and be queried with `| json`.
- json:
expressions:
level: level
service: service
environment: environment
version: version
logger: logger
- labels:
level:
service:
environment:
version:
logger:

View File

@ -44,6 +44,12 @@ To customize log storage locations and behaviors, set the following [Langflow en
| `LANGFLOW_ENABLE_LOG_RETRIEVAL` | Boolean | `False` | Enables retrieval of logs from your Langflow instance with [Logs endpoints](/api-logs). |
| `LANGFLOW_LOG_RETRIEVER_BUFFER_SIZE` | Integer | `10000` | Set the buffer size for log retrieval if `LANGFLOW_ENABLE_LOG_RETRIEVAL=True`. Must be greater than `0` for log retrieval to function. |
| `LANGFLOW_NATIVE_TRACING` | Boolean | `true` | Enables the tracer to record execution traces directly in the Langflow database for use in Trace View. Set to `false` to disable tracing. |
| `LANGFLOW_SERVICE_NAME` | String | `langflow` | Value of the `service` field on every JSON log record. Used as a label by Grafana/Loki. See [Grafana and Loki](./observability-grafana-loki.mdx). |
| `LANGFLOW_VERSION` | String | Not set | Adds a `version` field when set. Omitted otherwise. |
| `LANGFLOW_ENVIRONMENT` | String | Not set | Adds an `environment` field when set (`staging`, `production`, etc.). Omitted otherwise. |
| `LANGFLOW_LOG_LEVELS` | String | Not set | Per-logger level overrides as `name=LEVEL,name=LEVEL`. Quiets noisy libraries without changing global defaults. Malformed entries raise a `UserWarning` at startup. Example: `sqlalchemy.engine=WARNING,httpx=INFO`. |
| `LANGFLOW_LOG_REDACT_KEYS` | String | Not set | Comma-separated extra keys to scrub from JSON log records, on top of the defaults (`password`, `api_key`, `token`, `authorization`, `cookie`, etc.). |
| `LANGFLOW_LOG_TRACE_LOCALS` | Boolean | `false` | Include frame locals in structured tracebacks. Off by default because locals can leak secrets; enable only for local debugging. |
## View logs in real-time
@ -181,6 +187,7 @@ The log file is only created when Langflow Desktop runs. If you don't see a log
## See also
* [Grafana and Loki](./observability-grafana-loki.mdx)
* [Logs endpoints](/api-logs)
* [Memory management options](/memory)
* [Configure an external PostgreSQL database](/configuration-custom-database)

View File

@ -0,0 +1,102 @@
---
title: Grafana and Loki
slug: /observability-grafana-loki
---
Langflow emits structured JSON logs designed to be ingested by [Grafana Loki](https://grafana.com/oss/loki/) or any other log aggregator that understands one JSON object per line. This page covers the environment variables that control the JSON output and the reference Grafana dashboard shipped with the repository.
## Enable JSON mode
Set `LANGFLOW_LOG_ENV=container` (or `container_json`). Every line written to stdout becomes a JSON object that includes the event message, level, timestamp, logger name, exception structure, and the service metadata listed below.
```bash
LANGFLOW_LOG_ENV=container
LANGFLOW_LOG_LEVEL=INFO
LANGFLOW_SERVICE_NAME=langflow
LANGFLOW_VERSION=1.10.0
LANGFLOW_ENVIRONMENT=production
```
## Environment variables
The variables below are evaluated once at startup. They are read by `lfx.log.logger.configure()` and apply to both `langflow` and `lfx` log output.
| Variable | Format | Default | Description |
|---|---|---|---|
| `LANGFLOW_SERVICE_NAME` | String | `langflow` | Value of the `service` field on every log record. Surfaces as a Loki label for filtering. |
| `LANGFLOW_VERSION` | String | unset | Adds a `version` field when set. Omitted otherwise. |
| `LANGFLOW_ENVIRONMENT` | String | unset | Adds an `environment` field when set (`staging`, `production`, etc.). Omitted otherwise. |
| `LANGFLOW_LOG_LEVELS` | String | unset | Per-logger level overrides as `name=LEVEL,name=LEVEL`. Used to quiet noisy libraries in production without changing global defaults. Example: `LANGFLOW_LOG_LEVELS=sqlalchemy.engine=WARNING,httpx=INFO`. Malformed entries raise a `UserWarning` at startup so typos like `WARN` surface instead of being silently dropped. |
| `LANGFLOW_LOG_REDACT_KEYS` | String | unset | Comma-separated extra keys to scrub before serialization. Added on top of the default set: `password`, `passwd`, `secret`, `api_key`, `apikey`, `token`, `access_token`, `refresh_token`, `authorization`, `auth`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`. Matching is case-insensitive and walks nested dicts, lists, and tuples up to depth 4. |
| `LANGFLOW_LOG_TRACE_LOCALS` | Boolean | `false` | If `true`, includes frame locals in structured tracebacks. Off by default because locals can contain API keys, request bodies, and environment values. Enable only for local debugging. |
## What the JSON looks like
A typical record in JSON mode:
```json
{
"event": "incoming request",
"level": "info",
"logger": "langflow.api.run",
"timestamp": "2026-05-17T18:18:29.100798Z",
"service": "langflow",
"version": "1.10.0",
"environment": "production",
"user_id": "user-4823",
"flow_id": "flow-718",
"authorization": "***",
"cookie": "***",
"request_body": {
"input": "Tell me a joke",
"api_key": "***",
"session_id": "***"
}
}
```
Errors include the full structured exception:
```json
{
"event": "flow run failed",
"level": "error",
"logger": "langflow.api.run",
"timestamp": "2026-05-17T18:18:32.412Z",
"service": "langflow",
"exception": [
{
"exc_type": "ConnectionError",
"exc_value": "upstream returned 503",
"frames": [
{"filename": "/app/.../runner.py", "lineno": 142, "name": "run_flow"}
]
}
]
}
```
Frame locals are intentionally absent unless `LANGFLOW_LOG_TRACE_LOCALS=true`.
## Stdlib loggers
Third-party libraries that use Python's standard logging module (`uvicorn`, `sqlalchemy`, `httpx`, `langchain`, `asyncio`) are routed through the same JSON stream in container mode, whether you log to stdout or to `LANGFLOW_LOG_FILE`. Their original logger name is preserved on the `logger` field, so the same Grafana queries that work for application loggers also work for library output, and the same PII redaction is applied to their structured fields.
## OpenTelemetry trace correlation
If `opentelemetry-api` is installed and an active span is present, every log record automatically picks up `trace_id` and `span_id`. Use those fields to jump between logs and traces in Grafana. OpenTelemetry is not a hard dependency; if it is not installed, the processor is a no-op and nothing else changes.
## Other backends
The JSON format is not Loki-specific. It is one JSON object per line with standard fields (`timestamp`, `level`, `logger`, `service`, structured `exception`, optional `trace_id`/`span_id`), so any backend that ingests JSON logs works: Elasticsearch, Splunk, Datadog, and IBM Instana among them. Only the scrape side (Promtail, here) changes.
For IBM Instana, point the Instana host agent or an OTLP log exporter at the same stdout stream. Trace correlation works through the OpenTelemetry hook above: Instana's Python tracer is OpenTelemetry-based from version 3.0, so when it is active the `trace_id` and `span_id` on each record line up with the traces Instana already collects.
## Reference Grafana stack
A pre-provisioned Loki + Promtail + Grafana stack lives in `deploy/observability/grafana-loki/`. It includes the dashboard `Langflow Logs` with panels for structured tracebacks, PII redaction proof, the stdlib intercept path, and service/environment/version coverage. See the [README in that directory](https://github.com/langflow-ai/langflow/tree/main/deploy/observability/grafana-loki) for run instructions.
## See also
* [Logs](/logging)
* [Logs endpoints](/api-logs)

View File

@ -154,6 +154,7 @@ module.exports = {
label: "Observability",
items: [
"Develop/logging",
"Develop/observability-grafana-loki",
"Develop/traces",
{
type: "category",

View File

@ -29,7 +29,6 @@ from lfx.log.logger import (
buffer_writer,
configure,
log_buffer,
remove_exception_in_production,
setup_gunicorn_logger,
setup_uvicorn_logger,
)
@ -523,37 +522,27 @@ class TestLogProcessors:
assert serialized_data["level"] == "INFO"
assert serialized_data["module"] == "test_module"
def test_remove_exception_in_production(self):
"""Test remove_exception_in_production() removes exception info in prod."""
event_dict = {"event": "Test message", "exception": "Some exception", "exc_info": "Some exc info"}
def test_container_json_includes_structured_traceback(self, capsys):
"""JSON renderer must serialize exc_info as a structured traceback for Grafana."""
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("traceback-test")
try:
msg = "boom"
raise ValueError(msg)
except ValueError as exc:
log.error("connection failed", exc_info=exc) # noqa: TRY400 - exc_info kwarg path under test
# Import the actual module to access DEV
import sys
logger_module = sys.modules["lfx.log.logger"]
with patch.object(logger_module, "DEV", False): # noqa: FBT003
result = remove_exception_in_production(None, "error", event_dict)
# Should remove exception info in production
assert "exception" not in result
assert "exc_info" not in result
assert result["event"] == "Test message"
def test_remove_exception_in_development(self):
"""Test remove_exception_in_production() keeps exception info in dev."""
event_dict = {"event": "Test message", "exception": "Some exception", "exc_info": "Some exc info"}
# Import the actual module to access DEV
import sys
logger_module = sys.modules["lfx.log.logger"]
with patch.object(logger_module, "DEV", True): # noqa: FBT003
result = remove_exception_in_production(None, "error", event_dict)
# Should keep exception info in development
assert result["exception"] == "Some exception"
assert result["exc_info"] == "Some exc info"
assert result["event"] == "Test message"
out = capsys.readouterr().out.strip().splitlines()[-1]
record = json.loads(out)
assert record["event"] == "connection failed"
assert record["level"] == "error"
# dict_tracebacks emits an "exception" list with at least one frame.
assert isinstance(record.get("exception"), list)
assert record["exception"]
first = record["exception"][0]
assert first["exc_type"] == "ValueError"
assert first["exc_value"] == "boom"
assert first.get("frames")
def test_buffer_writer_with_buffer_disabled(self):
"""Test buffer_writer() when log buffer is disabled."""
@ -622,6 +611,271 @@ class TestConstants:
assert isinstance(level_value, int), f"Level {level_name} value {level_value} is not an integer"
class TestProductionObservability:
"""Best-practice production logging: redaction, logger name, stdlib intercept, per-logger levels."""
def setup_method(self):
# Fully reset structlog so a previous test's cached factory (which may
# hold a now-closed capsys pipe) cannot bleed into this test.
structlog.reset_defaults()
def teardown_method(self):
structlog.reset_defaults()
# Drop any InterceptHandler we installed so other tests aren't affected.
logging.root.handlers = [h for h in logging.root.handlers if not isinstance(h, InterceptHandler)]
def _emit_and_parse(self, capsys, fn):
fn()
out = capsys.readouterr().out.strip().splitlines()
return [json.loads(line) for line in out if line.startswith("{")]
def test_pii_redaction_top_level_and_nested(self, capsys):
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("redact.test")
records = self._emit_and_parse(
capsys,
lambda: log.info(
"login",
user="alice",
password="hunter2", # noqa: S106 - test fixture for redaction # pragma: allowlist secret
api_key="sk-leak", # pragma: allowlist secret
nested={"authorization": "Bearer xyz", "safe": "ok"}, # pragma: allowlist secret
),
)
rec = records[-1]
assert rec["password"] == "***" # noqa: S105
assert rec["api_key"] == "***"
assert rec["nested"]["authorization"] == "***"
assert rec["nested"]["safe"] == "ok"
assert rec["user"] == "alice"
def test_logger_name_in_json_output(self, capsys):
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("my.service.module")
records = self._emit_and_parse(capsys, lambda: log.info("hello"))
assert records[-1]["logger"] == "my.service.module"
def test_stdlib_intercept_forwards_exception(self, capsys):
configure(log_env="container", log_level="DEBUG", cache=False)
stdlib = logging.getLogger("third_party_lib")
def emit():
try:
msg = "upstream"
raise ConnectionError(msg)
except ConnectionError:
stdlib.error("call failed", exc_info=True)
records = self._emit_and_parse(capsys, emit)
rec = records[-1]
assert rec["event"] == "call failed"
assert rec["logger"] == "third_party_lib"
assert isinstance(rec.get("exception"), list)
assert rec["exception"][0]["exc_type"] == "ConnectionError"
def test_per_logger_level_overrides_via_env(self, monkeypatch):
monkeypatch.setenv("LANGFLOW_LOG_LEVELS", "noisy.lib=WARNING,other=ERROR")
configure(log_env="container", log_level="DEBUG", cache=False)
assert logging.getLogger("noisy.lib").level == logging.WARNING
assert logging.getLogger("other").level == logging.ERROR
def test_extra_redact_keys_via_env(self, capsys, monkeypatch):
monkeypatch.setenv("LANGFLOW_LOG_REDACT_KEYS", "session_id,internal_key")
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("redact.extra")
records = self._emit_and_parse(
capsys,
lambda: log.info("hi", session_id="abc", internal_key="xyz", safe="ok"),
)
rec = records[-1]
assert rec["session_id"] == "***"
assert rec["internal_key"] == "***"
assert rec["safe"] == "ok"
def test_traceback_locals_disabled_by_default(self, capsys):
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("locals.test")
secret_var = "sk-do-not-leak" # pragma: allowlist secret # noqa: F841, S105
def emit():
try:
msg = "boom"
raise RuntimeError(msg)
except RuntimeError as e:
log.error("trace", exc_info=e) # noqa: TRY400 - exc_info kwarg path under test
records = self._emit_and_parse(capsys, emit)
# The secret string must not appear anywhere in the rendered JSON.
# That is the real security property; field renames in
# ExceptionDictTransformer must not cause this test to pass by accident.
rendered = json.dumps(records[-1])
assert "sk-do-not-leak" not in rendered # pragma: allowlist secret
def test_traceback_locals_enabled_via_opt_in(self, capsys, monkeypatch):
# LANGFLOW_LOG_TRACE_LOCALS=true is the explicit opt-in for local
# debugging. Verifies the opt-in actually flips the safe default.
monkeypatch.setenv("LANGFLOW_LOG_TRACE_LOCALS", "true")
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("locals.optin")
def emit():
traceable_marker = "marker-locals-on" # noqa: F841 - must appear in frame locals
try:
msg = "boom"
raise RuntimeError(msg)
except RuntimeError as e:
log.error("trace", exc_info=e) # noqa: TRY400 - exc_info kwarg path under test
records = self._emit_and_parse(capsys, emit)
rendered = json.dumps(records[-1])
assert "marker-locals-on" in rendered
def test_intercept_handler_is_idempotent(self):
# Two configure() calls must leave exactly one InterceptHandler
# attached, with the second call's level winning. A regression here
# multiplies log volume in production.
configure(log_env="container", log_level="DEBUG", cache=False)
configure(log_env="container", log_level="WARNING", cache=False)
handlers = [h for h in logging.root.handlers if isinstance(h, InterceptHandler)]
assert len(handlers) == 1
assert handlers[0].level == logging.WARNING
def test_intercept_handler_not_installed_in_pretty_mode(self, monkeypatch):
# Pretty/console mode must NOT route stdlib through structlog,
# otherwise dev terminals get duplicated lines.
monkeypatch.setenv("LANGFLOW_PRETTY_LOGS", "true")
configure(log_env="", log_level="DEBUG", cache=False)
handlers = [h for h in logging.root.handlers if isinstance(h, InterceptHandler)]
assert handlers == []
def test_intercept_handler_forwards_stack_info(self, capsys):
# stack_info is the new behavior added to InterceptHandler.emit -
# locks in that stdlib `logger.error("...", stack_info=True)` survives.
configure(log_env="container", log_level="DEBUG", cache=False)
stdlib = logging.getLogger("third_party.stack")
records = self._emit_and_parse(
capsys,
lambda: stdlib.error("with stack", stack_info=True),
)
rec = records[-1]
assert rec["event"] == "with stack"
# structlog renders stack_info under the `stack` key.
assert "stack" in rec
assert "Stack (most recent call last)" in rec["stack"]
def test_intercept_handler_swallows_broken_format_args(self):
# A buggy library calling `logger.info("user %s", a, b)` must not
# raise into the caller from our handler. Exercise emit() directly
# to avoid pytest's caplog also handling the same record (which
# would re-raise the formatting error from its own handler).
configure(log_env="container", log_level="DEBUG", cache=False)
handler = InterceptHandler()
record = logging.LogRecord(
name="broken.lib",
level=logging.ERROR,
pathname=__file__,
lineno=1,
msg="user %s",
args=("alice", "extra-arg-not-allowed"),
exc_info=None,
)
# Must not raise; stdlib contract is to route through handleError.
with Path(os.devnull).open("w") as devnull, contextlib.redirect_stderr(devnull):
handler.emit(record)
def test_service_info_defaults_to_langflow(self, capsys):
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("svc.default")
records = self._emit_and_parse(capsys, lambda: log.info("hi"))
rec = records[-1]
assert rec["service"] == "langflow"
# version/environment are omitted when unset.
assert "version" not in rec
assert "environment" not in rec
def test_service_info_from_env_appears_in_records(self, capsys, monkeypatch):
monkeypatch.setenv("LANGFLOW_SERVICE_NAME", "lfx-runner")
monkeypatch.setenv("LANGFLOW_VERSION", "1.2.3")
monkeypatch.setenv("LANGFLOW_ENVIRONMENT", "staging")
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("svc.env")
records = self._emit_and_parse(capsys, lambda: log.info("hi"))
rec = records[-1]
assert rec["service"] == "lfx-runner"
assert rec["version"] == "1.2.3"
assert rec["environment"] == "staging"
def test_malformed_log_levels_emits_warning(self, monkeypatch):
# Typos like `WARN` instead of `WARNING` must surface, not silently
# drop. Operators need feedback that their config didn't apply.
monkeypatch.setenv(
"LANGFLOW_LOG_LEVELS",
"sqlalchemy.engine=WARN,good=INFO,broken,=NOLEVEL,empty=,a=NOTALEVEL",
)
with pytest.warns(UserWarning, match="LANGFLOW_LOG_LEVELS"):
configure(log_env="container", log_level="DEBUG", cache=False)
# Valid entry still applied past the bad ones.
assert logging.getLogger("good").level == logging.INFO
def test_container_csv_preserves_exception_text(self, capsys):
# Before the fix, container_csv silently dropped exceptions. Lock in
# that the traceback is rendered into the CSV-style row.
configure(log_env="container_csv", log_level="DEBUG", cache=False)
log = structlog.get_logger("csv.test")
try:
msg = "boom-csv"
raise ValueError(msg)
except ValueError as e:
log.error("oh no", exc_info=e) # noqa: TRY400 - exc_info kwarg path under test
out = capsys.readouterr().out
assert "ValueError" in out
assert "boom-csv" in out
def test_redaction_walks_lists_and_tuples(self, capsys):
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("redact.collections")
records = self._emit_and_parse(
capsys,
lambda: log.info(
"audit",
# pragma: allowlist secret
users_list=[{"password": "x", "name": "alice"}],
# pragma: allowlist secret
users_tuple=({"token": "y", "name": "bob"},),
),
)
rec = records[-1]
assert rec["users_list"][0]["password"] == "***" # noqa: S105
assert rec["users_list"][0]["name"] == "alice"
assert rec["users_tuple"][0]["token"] == "***" # noqa: S105
assert rec["users_tuple"][0]["name"] == "bob"
def test_redaction_depth_limit_documents_contract(self, capsys):
# The 4-level walk is a deliberate trade-off; this test pins it down
# so a future change to the depth constant has to be intentional.
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("redact.depth")
marker = "leak-at-5" # pragma: allowlist secret
deep = {"l1": {"l2": {"l3": {"l4": {"password": marker}}}}}
records = self._emit_and_parse(capsys, lambda: log.info("deep", payload=deep))
rec = records[-1]
# At depth 5 (beyond the limit) the password value is passed through.
assert rec["payload"]["l1"]["l2"]["l3"]["l4"]["password"] == marker
def test_otel_processor_no_op_when_no_span(self, capsys):
# OTel SDK may be installed but no span is active. Processor must
# produce records without trace_id/span_id rather than failing.
configure(log_env="container", log_level="DEBUG", cache=False)
log = structlog.get_logger("otel.nospan")
records = self._emit_and_parse(capsys, lambda: log.info("hi"))
rec = records[-1]
# Either keys are absent or both are present (if a real span is
# somehow active in this process); they must never half-render.
if "trace_id" in rec or "span_id" in rec:
assert "trace_id" in rec
assert "span_id" in rec
class TestEdgeCasesAndErrorConditions:
"""Test suite for edge cases and error conditions."""
@ -1327,3 +1581,128 @@ class TestBufferWriterBytesSerializationFix:
finally:
# Restore buffer state
log_buffer.max = original_max
class TestFileModeStdlibUnification:
"""JSON file mode must route third-party stdlib logs through structlog too.
Regression coverage for the bug where ``LANGFLOW_LOG_ENV=container`` plus
``LANGFLOW_LOG_FILE`` skipped the stdlib path entirely: uvicorn, sqlalchemy,
httpx, asyncio wrote plain text straight to the file, bypassing both JSON
rendering and PII redaction.
"""
def teardown_method(self):
for handler in logging.root.handlers[:]:
if isinstance(handler, logging.handlers.RotatingFileHandler):
logging.root.removeHandler(handler)
handler.close()
structlog.reset_defaults()
structlog.configure()
@staticmethod
def _read_records(log_file_path):
for handler in logging.root.handlers:
if hasattr(handler, "flush"):
handler.flush()
lines = [ln for ln in log_file_path.read_text().splitlines() if ln.strip()]
# Every line must be valid JSON. Plain-text stdlib output raises here.
return [json.loads(ln) for ln in lines]
def test_container_file_mode_stdlib_logs_are_json_with_logger_name(self):
"""A third-party stdlib log lands in the file as JSON carrying its logger name."""
with tempfile.TemporaryDirectory() as tmp_dir:
log_file_path = Path(tmp_dir) / "langflow.log"
configure(log_env="container", log_level="INFO", log_file=log_file_path, cache=False)
logging.getLogger("sqlalchemy.engine").warning("connecting to pool")
records = self._read_records(log_file_path)
assert records, "expected at least one JSON log line"
sa = [r for r in records if r.get("logger") == "sqlalchemy.engine"]
assert sa, f"sqlalchemy.engine record missing; loggers seen: {[r.get('logger') for r in records]}"
assert sa[0]["event"] == "connecting to pool"
assert sa[0]["level"] == "warning"
# Service metadata is attached to stdlib records too.
assert sa[0]["service"] == "langflow"
def test_container_file_mode_redacts_stdlib_extra(self):
"""PII redaction applies to structured fields on stdlib records in file mode."""
with tempfile.TemporaryDirectory() as tmp_dir:
log_file_path = Path(tmp_dir) / "langflow.log"
configure(log_env="container", log_level="INFO", log_file=log_file_path, cache=False)
logging.getLogger("httpx").warning(
"request sent",
extra={"authorization": "Bearer xyz"}, # pragma: allowlist secret
)
records = self._read_records(log_file_path)
hx = [r for r in records if r.get("logger") == "httpx"]
assert hx, f"httpx record missing; loggers seen: {[r.get('logger') for r in records]}"
assert hx[0].get("authorization") == "***"
def test_container_file_mode_app_logs_still_json_and_redacted(self):
"""Application logs keep their JSON + redaction in file mode (structlog path)."""
with tempfile.TemporaryDirectory() as tmp_dir:
log_file_path = Path(tmp_dir) / "langflow.log"
configure(log_env="container", log_level="INFO", log_file=log_file_path, cache=False)
structlog.get_logger("langflow.api").info(
"incoming",
api_key="sk-do-not-leak", # pragma: allowlist secret
)
records = self._read_records(log_file_path)
app = [r for r in records if r.get("logger") == "langflow.api"]
assert app, f"app record missing; loggers seen: {[r.get('logger') for r in records]}"
assert app[0]["event"] == "incoming"
assert app[0].get("api_key") == "***"
class TestInterceptExtraForwarding:
"""The stdout InterceptHandler forwards stdlib `extra` fields and redacts them."""
def teardown_method(self):
for handler in logging.root.handlers[:]:
if isinstance(handler, InterceptHandler):
logging.root.removeHandler(handler)
structlog.reset_defaults()
structlog.configure()
def test_intercept_forwards_and_redacts_stdlib_extra(self, capsys):
"""A stdlib `extra` lands as a structured field; sensitive keys are redacted, others kept."""
configure(log_env="container", log_level="INFO", cache=False)
logging.getLogger("sqlalchemy.engine").warning(
"checking out connection",
extra={"password": "hunter2", "pool_size": 5}, # pragma: allowlist secret
)
lines = [ln for ln in capsys.readouterr().out.strip().splitlines() if ln.strip().startswith("{")]
records = [json.loads(ln) for ln in lines]
sa = [r for r in records if r.get("logger") == "sqlalchemy.engine"]
assert sa, f"sqlalchemy.engine record missing; loggers seen: {[r.get('logger') for r in records]}"
assert sa[0]["password"] == "***" # noqa: S105 - sensitive extra redacted
assert sa[0]["pool_size"] == 5 # non-sensitive extra preserved
class TestRetrievalBufferMessage:
"""The /logs retrieval buffer must capture the actual message text, not an empty string."""
def teardown_method(self):
log_buffer.max = 0
structlog.reset_defaults()
structlog.configure()
def test_buffer_captures_message_text(self):
"""add_serialized writes the message under `message`; the buffer must read it back."""
log_buffer.max = 100
try:
configure(log_env="container", log_level="INFO", cache=False)
structlog.get_logger("demo").info("hello buffer world")
values = list(log_buffer.get_last_n(5).values())
assert values, "expected a buffered entry"
assert "hello buffer world" in values
finally:
log_buffer.max = 0

View File

@ -6,6 +6,7 @@ import logging
import logging.handlers
import os
import sys
import warnings
from collections import deque
from datetime import datetime
from pathlib import Path
@ -20,6 +21,13 @@ from typing_extensions import NotRequired
from lfx.settings import DEV
# OpenTelemetry is optional. Resolve once at import time so the per-record
# processor is a simple attribute check, not a repeated import attempt.
try:
from opentelemetry import trace as _otel_trace # type: ignore[import-not-found]
except ImportError:
_otel_trace = None
VALID_LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# Map log level names to integers
@ -58,7 +66,9 @@ class SizedLogBuffer:
def write(self, message: str) -> None:
"""Write a message to the buffer."""
record = json.loads(message)
log_entry = record.get("event", record.get("msg", record.get("text", "")))
# ``add_serialized`` stores the rendered text under ``message``; fall back to
# ``event`` / ``msg`` / ``text`` for records written directly in other shapes.
log_entry = record.get("message") or record.get("event", record.get("msg", record.get("text", "")))
# Extract timestamp - support both direct timestamp and nested record.time.timestamp
timestamp = record.get("timestamp", 0)
@ -178,14 +188,168 @@ def add_serialized(_logger: Any, _method_name: str, event_dict: dict[str, Any])
return event_dict
def remove_exception_in_production(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Remove exception details in production."""
if DEV is False:
event_dict.pop("exception", None)
event_dict.pop("exc_info", None)
def _get_service_info() -> dict[str, str]:
"""Read service metadata once so it can be injected into every log record."""
service = os.getenv("LANGFLOW_SERVICE_NAME", "langflow")
version = os.getenv("LANGFLOW_VERSION", "")
environment = os.getenv("LANGFLOW_ENVIRONMENT", "")
info = {"service": service}
if version:
info["version"] = version
if environment:
info["environment"] = environment
return info
# Default keys whose values are redacted before rendering. Production logs leak
# auth tokens, cookies, and API keys with surprising regularity (third-party
# clients log request bodies, dict reprs, kwargs, etc.); a cheap, default-on
# redactor is the only thing that survives.
DEFAULT_REDACT_KEYS: frozenset[str] = frozenset(
{
"password",
"passwd",
"secret",
"api_key",
"apikey",
"token",
"access_token",
"refresh_token",
"authorization",
"auth",
"cookie",
"set-cookie",
"x-api-key",
"x-auth-token",
}
)
_REDACTED = "***"
_REDACT_MAX_DEPTH = 4
def _build_redact_processor(extra_keys: frozenset[str]) -> Any:
"""Build a structlog processor that scrubs sensitive keys.
Matches case-insensitively, walks nested dicts and lists up to a small
depth, and replaces values with a fixed sentinel so logs still show the
shape of the data without leaking the value.
"""
sensitive = {k.lower() for k in DEFAULT_REDACT_KEYS | extra_keys}
def _scrub(value: Any, depth: int) -> Any:
if depth >= _REDACT_MAX_DEPTH:
return value
if isinstance(value, dict):
return {
k: (_REDACTED if isinstance(k, str) and k.lower() in sensitive else _scrub(v, depth + 1))
for k, v in value.items()
}
if isinstance(value, list):
return [_scrub(item, depth + 1) for item in value]
if isinstance(value, tuple):
return tuple(_scrub(item, depth + 1) for item in value)
return value
def redact(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
for key in list(event_dict.keys()):
if isinstance(key, str) and key.lower() in sensitive:
event_dict[key] = _REDACTED
else:
event_dict[key] = _scrub(event_dict[key], 1)
return event_dict
return redact
def add_logger_name(logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Attach the bound logger's name as ``logger`` so Grafana can filter on it."""
name = getattr(logger, "name", None)
if name:
event_dict.setdefault("logger", name)
return event_dict
class _NamedPrintLoggerFactory:
"""Logger factory that preserves the logger name across calls.
structlog's default ``PrintLoggerFactory`` drops the name passed to
``get_logger("x")``. We keep it so the ``add_logger_name`` processor can
set the ``logger`` field on every record.
"""
def __init__(self, file: Any) -> None:
self._file = file
def __call__(self, *args: Any) -> structlog.PrintLogger:
logger = structlog.PrintLogger(file=self._file)
logger.name = args[0] if args else None
return logger
def add_otel_trace_context(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Inject OpenTelemetry trace_id / span_id when a span is active.
OpenTelemetry is optional in lfx, so the import is resolved once at module
load. Runtime calls are wrapped in a broad except: a misbehaving tracer
SDK must never break logging, which is the only signal an operator has
when the tracer itself is broken.
"""
if _otel_trace is None:
return event_dict
try:
ctx = _otel_trace.get_current_span().get_span_context()
except Exception: # noqa: BLE001 - logger must never break on a flaky tracer
return event_dict
if not ctx.is_valid:
return event_dict
event_dict.setdefault("trace_id", format(ctx.trace_id, "032x"))
event_dict.setdefault("span_id", format(ctx.span_id, "016x"))
return event_dict
def _apply_logger_level_overrides() -> None:
"""Apply ``LANGFLOW_LOG_LEVELS`` env var: ``name=LEVEL,name=LEVEL,...``.
Used to quiet noisy third-party loggers (``sqlalchemy.engine``, ``httpx``,
``httpcore``, ``urllib3``) in production without changing global defaults.
Malformed entries (missing ``=``, unknown level, empty name) raise a
warning instead of being silently dropped so operators see typos like
``WARN`` instead of ``WARNING``.
"""
raw = os.getenv("LANGFLOW_LOG_LEVELS", "").strip()
if not raw:
return
for pair in raw.split(","):
entry = pair.strip()
if not entry:
continue
if "=" not in entry:
warnings.warn(
f"LANGFLOW_LOG_LEVELS: ignoring {entry!r} (expected 'name=LEVEL')",
stacklevel=2,
)
continue
name, _, level = entry.partition("=")
name = name.strip()
level_str = level.strip().upper()
if not name:
warnings.warn(
f"LANGFLOW_LOG_LEVELS: ignoring {entry!r} (empty logger name)",
stacklevel=2,
)
continue
numeric = LOG_LEVEL_MAP.get(level_str)
if numeric is None:
warnings.warn(
f"LANGFLOW_LOG_LEVELS: ignoring {entry!r} (unknown level {level_str!r}, "
f"expected one of {sorted(LOG_LEVEL_MAP)})",
stacklevel=2,
)
continue
logging.getLogger(name).setLevel(numeric)
def buffer_writer(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""Write to log buffer if enabled."""
if log_buffer.enabled() and "serialized" in event_dict:
@ -227,8 +391,14 @@ def setup_loguru_logger(log_level: str, *, enqueue: bool = False) -> None:
)
def setup_log_file(log_file: Path, *, max_bytes: int) -> None:
"""Set up Langflow's rotating file handler."""
def setup_log_file(log_file: Path, *, max_bytes: int, formatter: logging.Formatter | None = None) -> None:
"""Set up Langflow's rotating file handler.
``formatter`` lets JSON modes attach a ``structlog.stdlib.ProcessorFormatter``
so third-party stdlib records (uvicorn, sqlalchemy, httpx, ...) are rendered
as JSON through the same processor chain as application logs. When omitted,
the handler writes the message verbatim (structlog has already rendered it).
"""
global _file_handler # noqa: PLW0603
if _file_handler is not None:
@ -240,7 +410,7 @@ def setup_log_file(log_file: Path, *, max_bytes: int) -> None:
maxBytes=max_bytes,
backupCount=5,
)
_file_handler.setFormatter(logging.Formatter("%(message)s"))
_file_handler.setFormatter(formatter if formatter is not None else logging.Formatter("%(message)s"))
logging.root.addHandler(_file_handler)
@ -296,10 +466,25 @@ def configure(
log_format = os.getenv("LANGFLOW_LOG_FORMAT")
# Configure processors based on environment
processors = [
service_info = _get_service_info()
def _add_service_info(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
for key, value in service_info.items():
event_dict.setdefault(key, value)
return event_dict
extra_redact = frozenset(
k.strip().lower() for k in os.getenv("LANGFLOW_LOG_REDACT_KEYS", "").split(",") if k.strip()
)
redact_processor = _build_redact_processor(extra_redact)
processors: list[Any] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
add_logger_name,
add_otel_trace_context,
structlog.processors.TimeStamper(fmt="iso", utc=True),
_add_service_info,
]
# Add callsite information only when LANGFLOW_DEV is set
@ -316,16 +501,66 @@ def configure(
processors.extend(
[
redact_processor,
add_serialized,
remove_exception_in_production,
buffer_writer,
]
)
# Configure output based on environment
if log_env.lower() == "container" or log_env.lower() == "container_json":
processors.append(structlog.processors.JSONRenderer())
# Configure output based on environment.
# For machine-parseable renderers, serialize exc_info as structured tracebacks
# so Grafana/Loki see a complete stack trace (type, value, frames) instead of
# dropping the exception or rendering its repr. ConsoleRenderer formats
# exc_info itself, so we don't add a tracebacks processor on that path.
#
# `show_locals` is OFF by default in JSON output because frame locals can
# leak secrets (API keys, env, request bodies). Opt in with
# LANGFLOW_LOG_TRACE_LOCALS=true when you need it for local debugging.
show_locals = os.getenv("LANGFLOW_LOG_TRACE_LOCALS", "false").lower() == "true"
json_traceback = structlog.processors.ExceptionRenderer(
structlog.tracebacks.ExceptionDictTransformer(show_locals=show_locals, max_frames=50)
)
# When JSON output is written to a file, render through a stdlib
# ProcessorFormatter on the rotating handler instead of an inline
# JSONRenderer. That routes foreign stdlib records (uvicorn, sqlalchemy,
# httpx, asyncio) through the same renderer and the same redaction, so the
# file is a single JSON stream and PII redaction is not bypassed -- while the
# stdlib RotatingFileHandler keeps log rotation. Foreign records are enriched
# by ``foreign_pre_chain``; structlog records carry the context built above
# and are handed off via ``wrap_for_formatter``.
file_json_formatter: logging.Formatter | None = None
def _append_json_tail() -> None:
nonlocal file_json_formatter
if log_file:
processors.append(structlog.stdlib.ProcessorFormatter.wrap_for_formatter)
foreign_pre_chain = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.ExtraAdder(),
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
add_otel_trace_context,
structlog.processors.TimeStamper(fmt="iso", utc=True),
_add_service_info,
redact_processor,
]
file_json_formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=foreign_pre_chain,
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
json_traceback,
structlog.processors.JSONRenderer(),
],
)
else:
processors.append(json_traceback)
processors.append(structlog.processors.JSONRenderer())
if log_env.lower() in ("container", "container_json"):
_append_json_tail()
elif log_env.lower() == "container_csv":
processors.append(structlog.processors.format_exc_info)
# Include callsite fields in key order when DEV is enabled
key_order = ["timestamp", "level", "event"]
if DEV:
@ -338,11 +573,12 @@ def configure(
if log_stdout_pretty:
# If custom format is provided, use KeyValueRenderer with custom format
if log_format:
processors.append(structlog.processors.format_exc_info)
processors.append(structlog.processors.KeyValueRenderer())
else:
processors.append(structlog.dev.ConsoleRenderer(colors=True))
else:
processors.append(structlog.processors.JSONRenderer())
_append_json_tail()
# Get numeric log level
numeric_level = LOG_LEVEL_MAP.get(log_level.upper(), logging.ERROR)
@ -355,11 +591,17 @@ def configure(
# Default to stdout for backward compatibility, unless output_file is specified
log_output_file = output_file if output_file is not None else sys.stdout
# Wipe cached loggers before reconfiguring so any module that captured a
# logger before the real configure() call picks up the new processor chain
# (otherwise cache_logger_on_first_use=True binds the bootstrap chain
# permanently to that reference).
structlog.reset_defaults()
structlog.configure(
processors=processors,
wrapper_class=wrapper_class,
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(file=log_output_file)
logger_factory=_NamedPrintLoggerFactory(file=log_output_file)
if not log_file
else structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=cache if cache is not None else True,
@ -389,14 +631,29 @@ def configure(
else:
max_bytes = 10 * 1024 * 1024 # Default 10MB
# Since structlog doesn't have built-in rotation, we'll use stdlib logging for file output
setup_log_file(log_file, max_bytes=max_bytes)
# Since structlog doesn't have built-in rotation, we'll use stdlib logging for file output.
# In JSON file mode the formatter renders both structlog and foreign stdlib records as JSON.
setup_log_file(log_file, max_bytes=max_bytes, formatter=file_json_formatter)
logging.root.setLevel(numeric_level)
# Set up interceptors for uvicorn and gunicorn
setup_uvicorn_logger()
setup_gunicorn_logger()
# In JSON modes we want a single unified stdout stream: every stdlib log
# record (uvicorn access logs, sqlalchemy, httpx, langchain, asyncio)
# routed into structlog so it comes out as JSON instead of unstructured
# text. In non-JSON modes leave stdlib alone so dev console output stays
# readable.
json_mode = log_env.lower() in ("container", "container_json") or (
not log_env and os.getenv("LANGFLOW_PRETTY_LOGS", "true").lower() != "true"
)
if json_mode and not log_file:
_install_stdlib_intercept(numeric_level)
# Apply per-logger level overrides last so user env beats library defaults.
_apply_logger_level_overrides()
# Create the global logger instance
global logger # noqa: PLW0603
logger = structlog.get_logger()
@ -427,27 +684,72 @@ def setup_gunicorn_logger() -> None:
logging.getLogger("gunicorn.access").propagate = True
_STDLIB_LEVEL_TO_STRUCTLOG = (
(logging.CRITICAL, "critical"),
(logging.ERROR, "error"),
(logging.WARNING, "warning"),
(logging.INFO, "info"),
)
# Attributes present on a vanilla LogRecord. Anything else in record.__dict__ was
# attached via ``logging.*(..., extra={...})`` and is forwarded to structlog so it
# lands as a structured field (and is therefore subject to PII redaction), mirroring
# the ExtraAdder used on the file-mode ProcessorFormatter path.
_RESERVED_LOGRECORD_ATTRS = frozenset(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
class InterceptHandler(logging.Handler):
"""Intercept standard logging messages and route them to structlog."""
"""Route stdlib logging records into structlog.
Forwards ``exc_info`` and ``stack_info`` so library tracebacks (httpx,
sqlalchemy, langchain, uvicorn) survive into the JSON output. Without
this, errors raised inside third-party libraries log a one-line message
with no stack trace.
"""
def emit(self, record: logging.LogRecord) -> None:
"""Emit a log record by passing it to structlog."""
# Get corresponding structlog logger
logger_name = record.name
structlog_logger = structlog.get_logger(logger_name)
# Mirrors the stdlib Handler.emit safety net: a malformed third-party
# log call (e.g. mismatched %-format args) must not propagate up and
# crash the request path. Anything that raises here is routed to
# handleError, which is the documented contract callers expect.
try:
structlog_logger = structlog.get_logger(record.name)
kwargs: dict[str, Any] = {}
if record.exc_info:
kwargs["exc_info"] = record.exc_info
if record.stack_info:
# stdlib already formats stack_info as a string. Pass it as
# the rendered ``stack`` field directly so it survives without
# needing StackInfoRenderer to recompute from a different frame.
kwargs["stack"] = record.stack_info
for key, value in record.__dict__.items():
if key not in _RESERVED_LOGRECORD_ATTRS and not key.startswith("_") and key not in kwargs:
kwargs[key] = value
method_name = "debug"
for threshold, name in _STDLIB_LEVEL_TO_STRUCTLOG:
if record.levelno >= threshold:
method_name = name
break
getattr(structlog_logger, method_name)(record.getMessage(), **kwargs)
except Exception: # noqa: BLE001 - logging must never break the caller
self.handleError(record)
# Map log levels
level = record.levelno
if level >= logging.CRITICAL:
structlog_logger.critical(record.getMessage())
elif level >= logging.ERROR:
structlog_logger.error(record.getMessage())
elif level >= logging.WARNING:
structlog_logger.warning(record.getMessage())
elif level >= logging.INFO:
structlog_logger.info(record.getMessage())
else:
structlog_logger.debug(record.getMessage())
def _install_stdlib_intercept(numeric_level: int) -> None:
"""Install (or refresh) the InterceptHandler on the stdlib root logger.
Routes every stdlib log record (uvicorn, sqlalchemy, httpx, langchain,
asyncio, ...) into structlog so the entire process emits a single JSON
stream. Re-runnable: a second call updates the level rather than stacking
handlers.
"""
root = logging.root
handler = next((h for h in root.handlers if isinstance(h, InterceptHandler)), None)
if handler is None:
handler = InterceptHandler()
root.addHandler(handler)
handler.setLevel(numeric_level)
root.setLevel(numeric_level)
# Initialize logger - will be reconfigured when configure() is called