Skip to content

Commit bd09988

Browse files
authored
Merge pull request #842 from karrioapi/otel
2 parents e8cba55 + b223a97 commit bd09988

77 files changed

Lines changed: 2191 additions & 604 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.grafana-test

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# OpenTelemetry configuration for Grafana testing
2+
OTEL_ENABLED=true
3+
OTEL_SERVICE_NAME=karrio-api-local
4+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
5+
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
6+
OTEL_ENVIRONMENT=local-development
7+
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,service.namespace=karrio-dev,team=logistics
8+
9+
# Basic Django settings for local testing
10+
SECRET_KEY=test-key-for-grafana-development
11+
DEBUG=True
12+
DATABASE_URL=sqlite:////tmp/karrio_grafana_test.db
13+
14+
# Email settings to avoid errors
15+
EMAIL_FROM_ADDRESS=noreply@example.com
16+
EMAIL_HOST=smtp.example.com
17+
EMAIL_PORT=587
18+
EMAIL_HOST_USER=user@example.com
19+
EMAIL_HOST_PASSWORD=password
20+
DEFAULT_FROM_EMAIL=noreply@example.com
21+
22+
# Disable some features for testing
23+
DETACHED_WORKER=false
24+
ADMIN_EMAIL=admin@example.com
25+
ADMIN_PASSWORD=demo

.env.sample

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,33 @@ OIDC_RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIJKQIBAAKCAgEA92+93Tyg6
4646

4747
# Plugin Configuration
4848
ENABLE_ALL_PLUGINS_BY_DEFAULT=True
49+
50+
# =============================================================================
51+
# OPENTELEMETRY CONFIGURATION (OPTIONAL)
52+
# =============================================================================
53+
# Enable OpenTelemetry instrumentation
54+
# OTEL_ENABLED=false
55+
56+
# Service name for traces and metrics
57+
# OTEL_SERVICE_NAME=karrio-api
58+
59+
# OTLP Exporter endpoint (required if OTEL_ENABLED=true)
60+
# Examples:
61+
# - Jaeger: http://localhost:4317 (gRPC) or http://localhost:4318 (HTTP)
62+
# - Zipkin: http://localhost:9411/api/v2/spans
63+
# - OTLP Collector: http://localhost:4317
64+
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
65+
66+
# Protocol for OTLP exporter (grpc or http)
67+
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
68+
69+
# Optional headers for OTLP exporter (comma-separated key=value pairs)
70+
# Example: api-key=your-api-key,tenant-id=your-tenant
71+
# OTEL_EXPORTER_OTLP_HEADERS=
72+
73+
# Environment name for telemetry data
74+
# OTEL_ENVIRONMENT=development
75+
76+
# Additional resource attributes (comma-separated key=value pairs)
77+
# Example: team=backend,region=us-west-2
78+
# OTEL_RESOURCE_ATTRIBUTES=

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
- name: Set up Python
4747
uses: actions/setup-python@v4
4848
with:
49-
python-version: '3.12'
49+
python-version: "3.12"
5050

5151
- name: Install build dependencies
5252
run: |

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ node_modules
122122
.vercel
123123

124124
# karrio stuff:
125+
!apps/api/karrio/server/lib
125126
!apps/dashboard/src/lib
126127
!packages/lib
127128

129+
128130
.bash_history
129131

130132
.idea/

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
# Karrio 2025.5rc18
2+
3+
## Changes
4+
5+
### Feat
6+
7+
- feat: add support for OpenTelemetry to Karrio server
8+
9+
### Fix
10+
11+
- fix: missing workflow-trigger table migrations
12+
113
# Karrio 2025.5rc17
214

315
## Changes

apps/api/karrio/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2025.5rc17
1+
2025.5rc18
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""
2+
OpenTelemetry instrumentation for Huey task queue.
3+
4+
This module provides tracing support for Huey tasks, enabling distributed tracing
5+
across API requests and background tasks.
6+
"""
7+
import functools
8+
import logging
9+
from typing import Any, Callable, Dict, Optional
10+
11+
from opentelemetry import trace, context, propagate
12+
from opentelemetry.trace import Status, StatusCode
13+
from opentelemetry.semconv.trace import SpanAttributes
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class HueyInstrumentor:
19+
"""Instrumentation for Huey task queue."""
20+
21+
_instance = None
22+
_instrumented = False
23+
24+
def __new__(cls):
25+
if cls._instance is None:
26+
cls._instance = super().__new__(cls)
27+
return cls._instance
28+
29+
def instrument(self, huey_instance=None):
30+
"""
31+
Instrument Huey for OpenTelemetry tracing.
32+
33+
Args:
34+
huey_instance: The Huey instance to instrument. If None, will try to
35+
import from Django settings.
36+
"""
37+
if self._instrumented:
38+
logger.debug("Huey already instrumented")
39+
return
40+
41+
try:
42+
if huey_instance is None:
43+
from django.conf import settings
44+
huey_instance = settings.HUEY
45+
46+
# Wrap the task decorator
47+
original_task = huey_instance.task
48+
huey_instance.task = self._wrap_task_decorator(original_task, huey_instance)
49+
50+
# Wrap periodic tasks
51+
if hasattr(huey_instance, 'periodic_task'):
52+
original_periodic = huey_instance.periodic_task
53+
huey_instance.periodic_task = self._wrap_task_decorator(original_periodic, huey_instance)
54+
55+
self._instrumented = True
56+
logger.info("Huey instrumented for OpenTelemetry")
57+
58+
except Exception as e:
59+
logger.warning(f"Failed to instrument Huey: {e}")
60+
61+
def _wrap_task_decorator(self, original_decorator: Callable, huey_instance) -> Callable:
62+
"""Wrap the Huey task decorator to add tracing."""
63+
64+
@functools.wraps(original_decorator)
65+
def wrapped_decorator(*args, **kwargs):
66+
decorated = original_decorator(*args, **kwargs)
67+
68+
def task_wrapper(fn):
69+
task_fn = decorated(fn)
70+
71+
@functools.wraps(task_fn)
72+
def traced_task(*task_args, **task_kwargs):
73+
tracer = trace.get_tracer(__name__)
74+
75+
# Extract trace context from task kwargs if present
76+
trace_context = task_kwargs.pop('_otel_context', None)
77+
if trace_context:
78+
ctx = propagate.extract(trace_context)
79+
token = context.attach(ctx)
80+
else:
81+
token = None
82+
83+
# Start span for the task
84+
task_name = fn.__name__
85+
with tracer.start_as_current_span(
86+
f"huey.task.{task_name}",
87+
kind=trace.SpanKind.CONSUMER,
88+
) as span:
89+
try:
90+
# Set span attributes
91+
span.set_attribute("messaging.system", "huey")
92+
span.set_attribute("messaging.destination", task_name)
93+
span.set_attribute("messaging.operation", "process")
94+
span.set_attribute("task.name", task_name)
95+
96+
# Execute the task
97+
result = task_fn(*task_args, **task_kwargs)
98+
span.set_status(Status(StatusCode.OK))
99+
return result
100+
101+
except Exception as e:
102+
span.set_status(Status(StatusCode.ERROR, str(e)))
103+
span.record_exception(e)
104+
raise
105+
finally:
106+
if token:
107+
context.detach(token)
108+
109+
# Preserve original attributes
110+
traced_task.task = task_fn.task if hasattr(task_fn, 'task') else task_fn
111+
if hasattr(task_fn, '__name__'):
112+
traced_task.__name__ = task_fn.__name__
113+
if hasattr(task_fn, '__module__'):
114+
traced_task.__module__ = task_fn.__module__
115+
116+
return traced_task
117+
118+
return task_wrapper
119+
120+
return wrapped_decorator
121+
122+
123+
def inject_trace_context(task_kwargs: Dict[str, Any]) -> Dict[str, Any]:
124+
"""
125+
Inject current trace context into task kwargs for propagation.
126+
127+
This should be called when enqueuing a task to propagate the trace context
128+
to the background worker.
129+
130+
Args:
131+
task_kwargs: The kwargs to be passed to the task
132+
133+
Returns:
134+
Updated kwargs with trace context
135+
"""
136+
carrier = {}
137+
propagate.inject(carrier)
138+
if carrier:
139+
task_kwargs['_otel_context'] = carrier
140+
return task_kwargs
141+
142+
143+
def instrument_huey():
144+
"""Convenience function to instrument Huey."""
145+
instrumentor = HueyInstrumentor()
146+
instrumentor.instrument()

apps/api/karrio/server/settings/apm.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,128 @@
5656
# django.contrib.auth) you may enable sending PII data.
5757
send_default_pii=True,
5858
)
59+
60+
61+
# OpenTelemetry Configuration
62+
OTEL_ENABLED = config("OTEL_ENABLED", default=False, cast=bool)
63+
OTEL_SERVICE_NAME = config("OTEL_SERVICE_NAME", default="karrio-api")
64+
OTEL_EXPORTER_OTLP_ENDPOINT = config("OTEL_EXPORTER_OTLP_ENDPOINT", default=None)
65+
OTEL_EXPORTER_OTLP_PROTOCOL = config("OTEL_EXPORTER_OTLP_PROTOCOL", default="grpc")
66+
OTEL_EXPORTER_OTLP_HEADERS = config("OTEL_EXPORTER_OTLP_HEADERS", default="")
67+
OTEL_TRACES_EXPORTER = config("OTEL_TRACES_EXPORTER", default="otlp")
68+
OTEL_METRICS_EXPORTER = config("OTEL_METRICS_EXPORTER", default="otlp")
69+
OTEL_LOGS_EXPORTER = config("OTEL_LOGS_EXPORTER", default="otlp")
70+
OTEL_RESOURCE_ATTRIBUTES = config("OTEL_RESOURCE_ATTRIBUTES", default="")
71+
OTEL_ENVIRONMENT = config("OTEL_ENVIRONMENT", default=config("ENV", default="production"))
72+
73+
# Only initialize OpenTelemetry if enabled and endpoint is configured
74+
if OTEL_ENABLED and OTEL_EXPORTER_OTLP_ENDPOINT:
75+
import logging
76+
from opentelemetry import trace, metrics
77+
from opentelemetry.sdk.trace import TracerProvider
78+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
79+
from opentelemetry.sdk.metrics import MeterProvider
80+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
81+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
82+
from opentelemetry.instrumentation.django import DjangoInstrumentor
83+
from opentelemetry.instrumentation.requests import RequestsInstrumentor
84+
from opentelemetry.instrumentation.logging import LoggingInstrumentor
85+
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
86+
from opentelemetry.instrumentation.redis import RedisInstrumentor
87+
88+
# Import appropriate exporter based on protocol
89+
if OTEL_EXPORTER_OTLP_PROTOCOL == "grpc":
90+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
91+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
92+
else: # http/protobuf
93+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
94+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
95+
96+
# Parse headers if provided
97+
headers = {}
98+
if OTEL_EXPORTER_OTLP_HEADERS:
99+
for header_pair in OTEL_EXPORTER_OTLP_HEADERS.split(","):
100+
if "=" in header_pair:
101+
key, value = header_pair.split("=", 1)
102+
headers[key.strip()] = value.strip()
103+
104+
# Parse resource attributes
105+
resource_attributes = {
106+
SERVICE_NAME: OTEL_SERVICE_NAME,
107+
SERVICE_VERSION: config("VERSION", default="unknown"),
108+
"environment": OTEL_ENVIRONMENT,
109+
"deployment.environment": OTEL_ENVIRONMENT,
110+
}
111+
112+
if OTEL_RESOURCE_ATTRIBUTES:
113+
for attr_pair in OTEL_RESOURCE_ATTRIBUTES.split(","):
114+
if "=" in attr_pair:
115+
key, value = attr_pair.split("=", 1)
116+
resource_attributes[key.strip()] = value.strip()
117+
118+
# Create resource
119+
resource = Resource(attributes=resource_attributes)
120+
121+
# Configure Trace Provider
122+
trace_provider = TracerProvider(resource=resource)
123+
trace.set_tracer_provider(trace_provider)
124+
125+
# Configure span exporter
126+
span_exporter = OTLPSpanExporter(
127+
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
128+
headers=headers if headers else None,
129+
)
130+
span_processor = BatchSpanProcessor(span_exporter)
131+
trace_provider.add_span_processor(span_processor)
132+
133+
# Configure Metrics Provider
134+
metric_exporter = OTLPMetricExporter(
135+
endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
136+
headers=headers if headers else None,
137+
)
138+
metric_reader = PeriodicExportingMetricReader(
139+
exporter=metric_exporter,
140+
export_interval_millis=30000, # Export metrics every 30 seconds
141+
)
142+
meter_provider = MeterProvider(
143+
resource=resource,
144+
metric_readers=[metric_reader],
145+
)
146+
metrics.set_meter_provider(meter_provider)
147+
148+
# Instrument Django
149+
DjangoInstrumentor().instrument(
150+
is_sql_commentor_enabled=True, # Add trace context to SQL queries
151+
request_hook=lambda span, request: span.set_attribute("http.client_ip", request.META.get("REMOTE_ADDR", "")),
152+
response_hook=lambda span, request, response: span.set_attribute("http.response.size", len(response.content) if hasattr(response, 'content') else 0),
153+
)
154+
155+
# Instrument other libraries
156+
RequestsInstrumentor().instrument() # HTTP client requests
157+
LoggingInstrumentor().instrument(set_logging_format=True) # Add trace context to logs
158+
159+
# Instrument database if PostgreSQL is used
160+
if config("DATABASE_ENGINE", default="").endswith("postgresql"):
161+
try:
162+
Psycopg2Instrumentor().instrument()
163+
except Exception:
164+
pass # Psycopg2 might not be installed
165+
166+
# Instrument Redis if configured
167+
if config("REDIS_HOST", default=None):
168+
try:
169+
RedisInstrumentor().instrument()
170+
except Exception:
171+
pass # Redis might not be installed
172+
173+
# Instrument Huey task queue (temporarily disabled due to compatibility issues)
174+
# try:
175+
# from karrio.server.lib.otel_huey import instrument_huey
176+
# instrument_huey()
177+
# except Exception as e:
178+
# logger = logging.getLogger(__name__)
179+
# logger.warning(f"Failed to instrument Huey: {e}")
180+
181+
# Log that OpenTelemetry is enabled
182+
logger = logging.getLogger(__name__)
183+
logger.info(f"OpenTelemetry enabled: Service={OTEL_SERVICE_NAME}, Endpoint={OTEL_EXPORTER_OTLP_ENDPOINT}")

apps/api/karrio/server/settings/workers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,19 @@
5151
filename=WORKER_DB_FILE_NAME,
5252
**({"immediate": WORKER_IMMEDIATE_MODE} if WORKER_IMMEDIATE_MODE else {}),
5353
)
54+
55+
56+
# Apply OpenTelemetry instrumentation to Huey if enabled
57+
OTEL_ENABLED = decouple.config("OTEL_ENABLED", default=False, cast=bool)
58+
OTEL_EXPORTER_OTLP_ENDPOINT = decouple.config("OTEL_EXPORTER_OTLP_ENDPOINT", default=None)
59+
60+
if OTEL_ENABLED and OTEL_EXPORTER_OTLP_ENDPOINT:
61+
try:
62+
# Import and apply instrumentation to the Huey instance
63+
from karrio.server.lib.otel_huey import HueyInstrumentor
64+
instrumentor = HueyInstrumentor()
65+
instrumentor.instrument(HUEY)
66+
except Exception as e:
67+
import logging
68+
logger = logging.getLogger(__name__)
69+
logger.warning(f"Failed to instrument Huey in worker settings: {e}")

0 commit comments

Comments
 (0)