Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,22 @@ MINIO_ENDPOINT_SSL=0

# API key rate limit
API_KEY_RATE_LIMIT="60/minute"

# ----------------------------------------------------------------------------
# OpenTelemetry APM (self-hoster observability).
# Off by default. Flip OTEL_ENABLED to 1 to opt in.
# Point at any OTLP-compatible collector (otel-collector, Jaeger, Tempo,
# Datadog Agent, Honeycomb, Grafana Cloud, etc.).
# See docs/otel-api-observability/README.md for the full guide.
# ----------------------------------------------------------------------------
OTEL_ENABLED=0
# OTEL_SERVICE_NAME=plane-api
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG=0.1
# Deployment environment tag. Set OTEL_ENVIRONMENT (falls back to
# SENTRY_ENVIRONMENT) and the API emits deployment.environment.name.
# OTEL_ENVIRONMENT=prod
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=prod,service.version=v0.34.0
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20<token>
7 changes: 7 additions & 0 deletions apps/api/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")

# Bootstrap OpenTelemetry before Django imports so runserver and
# management commands are instrumented too. No-op unless OTEL_ENABLED=1.
from plane.observability.setup import configure_otel

configure_otel()

try:
from django.core.management import execute_from_command_line
except ImportError as exc:
Expand Down
14 changes: 8 additions & 6 deletions apps/api/plane/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@

import os

from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")

django_asgi_app = get_asgi_application()
# Bootstrap OpenTelemetry before Django wires up so DjangoInstrumentor can
# patch the ASGI handler. No-op unless OTEL_ENABLED=1.
from plane.observability.setup import configure_otel # noqa: E402

configure_otel()

from channels.routing import ProtocolTypeRouter # noqa: E402
from django.core.asgi import get_asgi_application # noqa: E402

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.


application = ProtocolTypeRouter({"http": get_asgi_application()})
64 changes: 55 additions & 9 deletions apps/api/plane/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
# Third party imports
from celery import Celery
from pythonjsonlogger.json import JsonFormatter
from celery.signals import after_setup_logger, after_setup_task_logger
from celery.signals import (
after_setup_logger,
after_setup_task_logger,
worker_process_shutdown,
)
from celery.schedules import crontab, schedule

# Module imports
Expand All @@ -19,6 +23,54 @@
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")

# Bootstrap OpenTelemetry before Celery wires up so CeleryInstrumentor can
# patch task execution. No-op unless OTEL_ENABLED=1.
from plane.observability.setup import configure_otel, flush_otel # noqa: E402
from plane.observability.logging import TraceContextFilter # noqa: E402

configure_otel()

# Whether to trace-correlate worker logs. Matches the Django LOGGING gate in
# plane/settings/{local,production}.py; the bootstrap in configure_otel() uses
# its own (superset) token check.
_OTEL_LOG_ENABLED = os.environ.get("OTEL_ENABLED", "0").strip().lower() in ("1", "true", "yes")
Comment on lines +33 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_OTEL_LOG_ENABLED truthy check is inconsistent with configure_otel()'s _is_enabled().

The celery.py gate accepts ("1", "true", "yes") after .strip().lower(), but configure_otel() treats "on" and "ON" as enabled (per test_truthy_values_for_otel_enabled). Setting OTEL_ENABLED=on would enable tracing and instrumentation but silently skip trace-correlated logging, defeating log-trace correlation. The comment at line 35 acknowledges the mismatch but doesn't resolve it.

🔧 Proposed fix: add `"on"` to the truthy tuple
-_OTEL_LOG_ENABLED = os.environ.get("OTEL_ENABLED", "0").strip().lower() in ("1", "true", "yes")
+_OTEL_LOG_ENABLED = os.environ.get("OTEL_ENABLED", "0").strip().lower() in ("1", "true", "yes", "on")

The same fix should be applied to the Django LOGGING gate in plane/settings/local.py and plane/settings/production.py, which the comment says uses the same check. Consider extracting the truthy check into a shared helper in plane.observability.setup to prevent future drift.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_OTEL_LOG_ENABLED = os.environ.get("OTEL_ENABLED", "0").strip().lower() in ("1", "true", "yes")
_OTEL_LOG_ENABLED = os.environ.get("OTEL_ENABLED", "0").strip().lower() in ("1", "true", "yes", "on")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/celery.py` at line 36, Align the _OTEL_LOG_ENABLED check in
celery.py with configure_otel()’s _is_enabled() by accepting “on” as a truthy
value, and apply the same update to the Django LOGGING gates in local.py and
production.py. Prefer reusing a shared truthiness helper from
plane.observability.setup if available or introduce one to prevent these checks
from diverging.


# Base JSON log fmt (unchanged off-path); the OTel variant appends the
# trace-context fields that TraceContextFilter populates.
_CELERY_LOG_FMT = '"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
_CELERY_OTEL_LOG_FMT = (
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s '
"%(service_name)s %(trace_id)s %(span_id)s %(trace_flags)s"
)


@worker_process_shutdown.connect
def flush_otel_on_worker_shutdown(*args, **kwargs):
"""Flush buffered spans/metrics when a prefork child exits.

Prefork children exit via os._exit and skip atexit, so without this the tail
of each child's telemetry is dropped on --max-tasks-per-child recycling and
warm shutdown. No-op (bounded, never raises) unless OTel was configured.
"""
flush_otel()


def _build_celery_log_handler() -> logging.Handler:
"""Build the worker's JSON StreamHandler.

Off-path: identical to the historical handler (same fmt). When OTel logging
is enabled, use the extended fmt and attach TraceContextFilter so worker
log lines carry trace_id/span_id/service_name, matching the Django request
path.
"""
fmt = _CELERY_OTEL_LOG_FMT if _OTEL_LOG_ENABLED else _CELERY_LOG_FMT
handler = logging.StreamHandler()
handler.setFormatter(fmt=JsonFormatter(fmt))
if _OTEL_LOG_ENABLED:
handler.addFilter(TraceContextFilter())
return handler


ri = redis_instance()

# Configurable metrics push interval (in minutes)
Expand Down Expand Up @@ -98,18 +150,12 @@ def _get_metrics_push_interval_minutes() -> int:
# Setup logging
@after_setup_logger.connect
def setup_loggers(logger, *args, **kwargs):
formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s')
handler = logging.StreamHandler()
handler.setFormatter(fmt=formatter)
logger.addHandler(handler)
logger.addHandler(_build_celery_log_handler())


@after_setup_task_logger.connect
def setup_task_loggers(logger, *args, **kwargs):
formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s')
handler = logging.StreamHandler()
handler.setFormatter(fmt=formatter)
logger.addHandler(handler)
logger.addHandler(_build_celery_log_handler())


# Load task modules from all registered Django app configs.
Expand Down
3 changes: 3 additions & 0 deletions apps/api/plane/observability/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
37 changes: 37 additions & 0 deletions apps/api/plane/observability/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""Trace-context log correlation for the API.

`TraceContextFilter` adds trace_id / span_id / trace_flags / service_name
attributes to every LogRecord. Empty values when no span is active.

The filter is wired in via Django's LOGGING dict (see
plane/settings/local.py and plane/settings/production.py) and attached to
every handler, so it works regardless of logger propagation settings.
configure_otel() does not install it at runtime — Django's dictConfig
would wipe a runtime-installed filter when settings are applied.
"""

import logging
import os

from opentelemetry import trace


class TraceContextFilter(logging.Filter):
"""Inject trace_id, span_id, trace_flags, service_name into LogRecord."""

def filter(self, record: logging.LogRecord) -> bool:
ctx = trace.get_current_span().get_span_context()
if ctx.is_valid:
record.trace_id = format(ctx.trace_id, "032x")
record.span_id = format(ctx.span_id, "016x")
record.trace_flags = int(ctx.trace_flags)
else:
record.trace_id = ""
record.span_id = ""
record.trace_flags = 0
record.service_name = os.environ.get("OTEL_SERVICE_NAME", "plane-api")
return True
Loading
Loading