Skip to content

Commit 83e9621

Browse files
committed
fix: Ensure logging is user defined
1 parent 214926f commit 83e9621

10 files changed

Lines changed: 119 additions & 47 deletions

File tree

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ DJANGO_O11Y = {
2727

2828
| Setting | Type | Default | Env Var |
2929
|---------|------|---------|---------|
30-
| `TRACING.ENABLED` | bool | `True` | `DJANGO_O11Y_TRACING_ENABLED` |
30+
| `TRACING.ENABLED` | bool | `False` | `DJANGO_O11Y_TRACING_ENABLED` |
3131
| `TRACING.OTLP_ENDPOINT` | str | `"http://localhost:4317"` | `OTEL_EXPORTER_OTLP_ENDPOINT` |
3232
| `TRACING.SAMPLE_RATE` | float | `1.0` | `OTEL_TRACES_SAMPLER_ARG` |
3333
| `TRACING.CONSOLE_EXPORTER` | bool | `False` | `DJANGO_O11Y_CONSOLE_EXPORTER` |

docs/usage.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,39 @@ Structured logging via [Structlog](https://www.structlog.org/) with automatic tr
137137

138138
### Setup
139139

140-
Logging is configured by calling `build_logging_dict()` in your settings. Django applies it through the standard `LOGGING` setting — no magic, no auto-configuration.
140+
Call `build_logging_dict()` in each settings file. The defaults are keyed off `DEBUG`, so most of the difference between environments is handled automatically.
141+
142+
`LOGGING_CONFIG = None` is required in every settings file. Without it Django applies its own default handlers at startup, which causes duplicate access logs and Werkzeug's color handler showing up alongside structlog output.
143+
144+
**`settings/local.py`**
145+
146+
```python
147+
from django_o11y.logging.config import build_logging_dict
148+
149+
LOGGING_CONFIG = None
150+
LOGGING = build_logging_dict()
151+
# DEBUG=True: console format, colorized, file output to /tmp/django-o11y/django.log
152+
```
153+
154+
**`settings/production.py`**
141155

142156
```python
143-
# settings.py
144157
from django_o11y.logging.config import build_logging_dict
145158

146-
LOGGING_CONFIG = None # prevent Django applying its DEFAULT_LOGGING first
159+
LOGGING_CONFIG = None
147160
LOGGING = build_logging_dict()
161+
# DEBUG=False: JSON format, no file output
148162
```
149163

150-
`LOGGING_CONFIG = None` is required. Without it Django applies its own default handlers before your app config is ready, which causes duplicate access logs and Werkzeug's color handler showing up alongside structlog output.
164+
**`settings/test.py`**
165+
166+
```python
167+
from django_o11y.logging.config import build_logging_dict
168+
169+
LOGGING_CONFIG = None
170+
LOGGING = build_logging_dict({"LEVEL": "WARNING", "FILE_ENABLED": False})
171+
# Quiet in tests regardless of DEBUG
172+
```
151173

152174
### Usage
153175

@@ -307,12 +329,15 @@ Distributed tracing via [OpenTelemetry](https://opentelemetry.io/). Django reque
307329
```python
308330
DJANGO_O11Y = {
309331
"TRACING": {
332+
"ENABLED": True, # default: False
310333
"OTLP_ENDPOINT": "http://localhost:4317",
311-
"SAMPLE_RATE": 1.0, # 1.0 = 100%, use 0.01 in high-traffic prod
334+
"SAMPLE_RATE": 1.0, # 1.0 = 100%, use 0.01 in high-traffic prod
312335
}
313336
}
314337
```
315338

339+
To disable tracing entirely, set `ENABLED: False` or `DJANGO_O11Y_TRACING_ENABLED=false`.
340+
316341
### Adding custom context
317342

318343
```python

src/django_o11y/apps.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
"""Django app configuration for django-o11y."""
22

3-
import os
3+
import logging
44
from importlib.metadata import PackageNotFoundError, version
55

66
from django.apps import AppConfig
77
from django.conf import settings
88

9+
logger = logging.getLogger("django_o11y")
10+
911

1012
class DjangoO11yConfig(AppConfig):
1113
"""Django app configuration that sets up observability on startup."""
1214

1315
default_auto_field = "django.db.models.BigAutoField"
1416
name = "django_o11y"
1517
verbose_name = "Django O11y"
18+
_o11y_ready: bool = False
1619

1720
def ready(self):
1821
"""Initialize observability when Django starts."""
@@ -35,19 +38,32 @@ def ready(self):
3538
)
3639
raise ImproperlyConfigured(error_msg)
3740

38-
# runserver spawns a reloader process and a worker process, both calling
39-
# ready(). Django sets DJANGO_AUTORELOAD_ENV in the reloader process;
40-
# skip setup there to avoid double initialisation and a duplicate banner.
41-
if os.environ.get("DJANGO_AUTORELOAD_ENV"):
41+
# Prevent double initialisation. AppConfig.ready() can be called more
42+
# than once in some environments (e.g. Django's runserver reloader).
43+
if getattr(self, "_o11y_ready", False):
4244
return
45+
self._o11y_ready = True
4346

4447
if config["TRACING"]["ENABLED"]:
4548
setup_tracing(config)
49+
else:
50+
logger.info("Tracing disabled")
4651

4752
setup_instrumentation(config)
4853

54+
logging_config = config.get("LOGGING", {})
55+
fmt = logging_config.get("FORMAT", "console")
56+
logger.info("Logging configured, format=%s", fmt)
57+
58+
metrics_config = config.get("METRICS", {})
59+
if metrics_config.get("PROMETHEUS_ENABLED", True):
60+
endpoint = metrics_config.get("PROMETHEUS_ENDPOINT", "/metrics")
61+
logger.info("Metrics enabled at %s", endpoint)
62+
4963
if config.get("PROFILING", {}).get("ENABLED"):
5064
setup_profiling(config)
65+
else:
66+
logger.info("Profiling disabled")
5167

5268
try:
5369
if settings.DEBUG:
@@ -79,21 +95,26 @@ def _print_startup_banner(self, config: dict) -> None:
7995
sample = tracing.get("SAMPLE_RATE", 1.0)
8096
banner.append(f"✅ Tracing → {endpoint} ({sample * 100:.0f}% sampling)")
8197

98+
logging_config = config.get("LOGGING", {})
99+
fmt = logging_config.get("FORMAT", "console")
100+
banner.append(f"✅ Logging → format={fmt}")
101+
102+
metrics_config = config.get("METRICS", {})
103+
if metrics_config.get("PROMETHEUS_ENABLED", True):
104+
endpoint = metrics_config.get("PROMETHEUS_ENDPOINT", "/metrics")
105+
banner.append(f"✅ Metrics → {endpoint}")
106+
82107
if config.get("CELERY", {}).get("ENABLED"):
83108
banner.append("✅ Celery → auto-instrumented")
84109

85110
profiling = config.get("PROFILING", {})
86111
if profiling.get("ENABLED"):
87112
url = profiling.get("PYROSCOPE_URL", "")
88113
banner.append(f"✅ Profiling → {url}")
89-
else:
90-
banner.append("⚠️ Profiling → disabled")
91114

92115
banner.extend(
93116
[
94117
"",
95-
"Metrics: /metrics",
96-
"Health Check: python manage.py o11y check",
97118
"=" * 60,
98119
"",
99120
]

src/django_o11y/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def get_config() -> dict[str, Any]:
3030
"RESOURCE_ATTRIBUTES": {},
3131
"CUSTOM_TAGS": {},
3232
"TRACING": {
33-
"ENABLED": _get_bool_env("DJANGO_O11Y_TRACING_ENABLED", True),
33+
"ENABLED": _get_bool_env("DJANGO_O11Y_TRACING_ENABLED", False),
3434
"OTLP_ENDPOINT": os.getenv(
3535
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
3636
),

src/django_o11y/logging/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ def build_logging_dict(
121121
},
122122
"loggers": {
123123
"django_o11y": {
124+
"handlers": root_handlers,
124125
"level": cfg["LEVEL"],
126+
"propagate": False,
125127
},
126128
"django_structlog": {
127129
"level": cfg["LEVEL"],

src/django_o11y/management/commands/o11y.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def check():
253253
summary += f", {error_count} error"
254254
click.secho(summary, fg="red")
255255
raise SystemExit(1)
256-
elif warning_count > 0: # pragma: no cover
256+
if warning_count > 0: # pragma: no cover
257257
click.secho(summary, fg="yellow")
258258
else:
259259
click.secho(summary, fg="green")
@@ -316,6 +316,23 @@ def _get_compose_cmd():
316316
return ["docker-compose"] # pragma: no cover
317317

318318

319+
def _copy_stack_file(config_file, dest, app_url=None, app_container=None):
320+
"""Copy a single stack config file, substituting placeholders if needed."""
321+
# For alloy-config.alloy, substitute the metrics scrape URL
322+
# and the Docker container name for log scraping
323+
if config_file.name == "alloy-config.alloy" and (
324+
app_url or app_container
325+
): # pragma: no cover
326+
content = config_file.read_text()
327+
if app_url:
328+
content = content.replace('"host.docker.internal:8000"', f'"{app_url}"')
329+
if app_container:
330+
content = content.replace('"django-app"', f'"{app_container}"')
331+
dest.write_text(content)
332+
else:
333+
shutil.copy(config_file, dest)
334+
335+
319336
def _get_work_dir(app_url=None, app_container=None):
320337
"""Get or create working directory and copy stack configs."""
321338
work_dir = Path.home() / ".django-o11y"
@@ -331,24 +348,7 @@ def _get_work_dir(app_url=None, app_container=None):
331348
for config_file in stack_path.glob("*"):
332349
if config_file.is_file():
333350
dest = work_dir / config_file.name
334-
335-
# For alloy-config.alloy, substitute the metrics scrape URL
336-
# and the Docker container name for log scraping
337-
if config_file.name == "alloy-config.alloy" and (
338-
app_url or app_container
339-
): # pragma: no cover
340-
content = config_file.read_text()
341-
if app_url:
342-
content = content.replace(
343-
'"host.docker.internal:8000"', f'"{app_url}"'
344-
)
345-
if app_container:
346-
content = content.replace(
347-
'"django-app"', f'"{app_container}"'
348-
)
349-
dest.write_text(content)
350-
else:
351-
shutil.copy(config_file, dest)
351+
_copy_stack_file(config_file, dest, app_url, app_container)
352352
except Exception as e: # pragma: no cover
353353
click.secho(f"Warning: Could not copy stack files: {e}", fg="yellow")
354354

src/django_o11y/management/commands/stack/docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
services:
22
tempo:
33
image: grafana/tempo:2.10.1
4+
user: "10001:10001"
45
command: ["-config.file=/etc/tempo.yaml"]
56
volumes:
67
- ./tempo-config.yaml:/etc/tempo.yaml
7-
tmpfs:
8-
- /tmp/tempo
8+
- tempo-data:/var/tempo
99
ports:
1010
- "3200:3200"
1111

src/django_o11y/management/commands/stack/tempo-config.yaml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@ storage:
1414
trace:
1515
backend: local
1616
wal:
17-
path: /tmp/tempo/wal
17+
path: /var/tempo/wal
1818
local:
19-
path: /tmp/tempo/blocks
20-
19+
path: /var/tempo/blocks
20+
2121
ingester:
2222
max_block_duration: 5m
2323

2424
metrics_generator:
2525
storage:
26-
path: /tmp/tempo/generator/wal
26+
path: /var/tempo/generator/wal
27+
remote_write:
28+
- url: http://prometheus:9090/api/v1/write
29+
traces_storage:
30+
path: /var/tempo/generator/traces
2731

2832
overrides:
2933
defaults:
3034
metrics_generator:
31-
processors: [local-blocks]
35+
processors: [local-blocks, service-graphs, span-metrics]

src/django_o11y/profiling/__init__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ def setup_profiling(config: dict[str, Any]) -> None:
2626
)
2727
return
2828

29+
# service_name is already set via application_name; including it here
30+
# produces a duplicate label that Pyroscope rejects with 400.
2931
tags = {
30-
"service_name": config["SERVICE_NAME"],
3132
"service_version": os.getenv("SERVICE_VERSION", __version__),
3233
"environment": config.get("ENVIRONMENT", "development"),
3334
"host": socket.gethostname(),
@@ -41,8 +42,18 @@ def setup_profiling(config: dict[str, Any]) -> None:
4142
if custom_tags:
4243
tags.update(custom_tags)
4344

44-
pyroscope.configure(
45-
application_name=config["SERVICE_NAME"],
46-
server_address=profiling_config["PYROSCOPE_URL"],
47-
tags=tags,
45+
try:
46+
pyroscope.configure(
47+
application_name=config["SERVICE_NAME"],
48+
server_address=profiling_config["PYROSCOPE_URL"],
49+
tags=tags,
50+
)
51+
except Exception as e:
52+
logger.warning("django_o11y: profiling configuration failed: %s", e)
53+
raise
54+
55+
logger.info(
56+
"Profiling configured for %s, sending to %s",
57+
config["SERVICE_NAME"],
58+
profiling_config["PYROSCOPE_URL"],
4859
)

src/django_o11y/tracing/provider.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""OpenTelemetry tracing provider setup."""
22

3+
import logging
34
import os
45
import socket
56
from typing import Any
@@ -12,6 +13,8 @@
1213

1314
from django_o11y import __version__
1415

16+
logger = logging.getLogger("django_o11y.tracing")
17+
1518

1619
def setup_tracing(config: dict[str, Any]) -> TracerProvider:
1720
"""
@@ -55,4 +58,10 @@ def setup_tracing(config: dict[str, Any]) -> TracerProvider:
5558
provider.add_span_processor(BatchSpanProcessor(console_exporter))
5659

5760
trace.set_tracer_provider(provider)
61+
logger.info(
62+
"Tracing configured for %s, sending to %s (%.0f%% sampling)",
63+
service_name,
64+
tracing_config["OTLP_ENDPOINT"],
65+
tracing_config.get("SAMPLE_RATE", 1.0) * 100,
66+
)
5867
return provider

0 commit comments

Comments
 (0)