Observability¶
Nori does not bundle Sentry, Datadog, or OpenTelemetry in the core — those SDKs change fast and carry weight, and not every site wants the same one. What Nori provides is a single, correctly-timed extension point so you can plug any observability SDK into your site in a few lines.
That extension point is the bootstrap hook.
Why timing matters¶
Observability SDKs work by patching third-party libraries at import time — Sentry hooks into httpx, asyncpg, starlette; OpenTelemetry auto-instruments dozens of libraries. If you call sentry_sdk.init() after those libraries have been imported, the patches are silently incomplete and some telemetry never fires.
The bootstrap hook runs before Nori imports Starlette, Tortoise, or any other instrumentable library, so every subsequent import sees the instrumented versions.
Creating the hook¶
Create rootsystem/application/bootstrap.py:
That is the entire contract:
- The file is optional. If it does not exist, Nori starts normally.
- Define a top-level function named
bootstrapthat takes no arguments. - If
bootstrap()raises, a warning is logged on thenori.bootstraplogger and the app still starts — a broken hook never crashes the server.
The file lives in user-land. framework:update never touches it.
Recipe: Sentry¶
1. Install the SDK¶
2. Create rootsystem/application/bootstrap.py¶
import os
def bootstrap() -> None:
dsn = os.environ.get('SENTRY_DSN')
if not dsn:
return
import sentry_sdk
sentry_sdk.init(
dsn=dsn,
environment=os.environ.get('SENTRY_ENV', 'development'),
release=os.environ.get('RELEASE_SHA'),
traces_sample_rate=float(os.environ.get('SENTRY_TRACES_RATE', '0.1')),
send_default_pii=False,
)
3. Set the environment variables¶
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project>
SENTRY_ENV=production
SENTRY_TRACES_RATE=0.1
RELEASE_SHA=<commit-sha>
That is it. Sentry picks up uncaught exceptions from controllers and middleware, traces requests, and ties reports to your release SHA.
Notes¶
- Sensitive data:
send_default_pii=Falseis the safe default. Nori already exposesprotected_fieldson models to keep password hashes and tokens out ofto_dict(); Sentry complements that by not capturing request bodies or cookies unless you opt in. - Sample rate:
1.0captures every transaction (expensive).0.1captures 10%. Tune based on traffic and your Sentry quota. - Skip in tests: the
if not dsn: returnguard keeps Sentry silent whenSENTRY_DSNis unset — perfect for CI and local development.
Recipe: OpenTelemetry¶
OpenTelemetry is the vendor-neutral standard. Same trace data goes to Jaeger, Honeycomb, Datadog, New Relic, Grafana Tempo, or any other OTLP-compatible backend.
1. Install the SDK and the auto-instrumentation packages¶
pip install \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp \
opentelemetry-instrumentation-starlette \
opentelemetry-instrumentation-asyncpg \
opentelemetry-instrumentation-httpx
Pick the instrumentation packages that match the libraries your site actually uses. asyncpg for Postgres, aiomysql / asyncmy for MySQL, redis if you use the Redis cache or throttle backend, httpx for outbound requests.
2. Create rootsystem/application/bootstrap.py¶
import os
def bootstrap() -> None:
endpoint = os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT')
if not endpoint:
return
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Configure the global tracer provider.
resource = Resource.create({
SERVICE_NAME: os.environ.get('OTEL_SERVICE_NAME', 'nori-app'),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)))
trace.set_tracer_provider(provider)
# Auto-instrument the libraries that ship telemetry.
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
AsyncPGInstrumentor().instrument()
HTTPXClientInstrumentor().instrument()
StarletteInstrumentor.instrument() # Starlette uses a class method.
3. Set the environment variables¶
You also need an OTLP collector running somewhere — typically the OpenTelemetry Collector deployed alongside your app, forwarding to your backend of choice.
Notes¶
StarletteInstrumentor.instrument()wraps every request in a span tagged with the route, method, and status. Combined with theasyncpginstrumentation, you get a flame graph showing exactly which SQL query inside a request is slow.- Order does not matter inside
bootstrap()— instrumentation hooks register globally and apply when the libraries are imported by Nori afterwards. BatchSpanProcessorbuffers spans and flushes asynchronously.SimpleSpanProcessorflushes per-span (synchronous, slower, useful for debugging only).
Recipe: Datadog¶
Datadog ships its own SDK (ddtrace) that auto-instruments most popular Python libraries with one call.
Option A — bootstrap hook¶
# rootsystem/application/bootstrap.py
import os
def bootstrap() -> None:
if not os.environ.get('DD_TRACE_ENABLED', '').lower() in ('1', 'true', 'yes'):
return
from ddtrace import patch_all
patch_all() # Patches starlette, asyncpg, httpx, redis, and everything else ddtrace knows about.
Option B — ddtrace-run wrapper¶
Datadog also ships a wrapper that patches before any application code runs, skipping the bootstrap hook entirely:
Use Option A if you want the patching decision to be visible in your codebase. Use Option B if your deployment platform standardises on ddtrace-run (Datadog's Kubernetes integration uses it by default).
Environment¶
Datadog reads agent connection info from environment variables — typically:
DD_TRACE_ENABLED=true
DD_AGENT_HOST=datadog-agent
DD_TRACE_AGENT_PORT=8126
DD_SERVICE=my-nori-app
DD_ENV=production
DD_VERSION=<commit-sha>
Correlating Request-ID with traces¶
Since v1.11.0 Nori automatically attaches a request_id to every log record under an HTTP request (including from asyncio.create_task background work). You can copy that same ID onto observability spans so a single trace correlates logs ↔ spans ↔ external service calls.
The current ID is available via core.http.request_id.get_request_id():
OpenTelemetry — copy as a span attribute¶
Wrap your handler entry points (or use a Starlette middleware after RequestIdMiddleware) to tag the active span:
from opentelemetry import trace
from core.http.request_id import get_request_id
def tag_current_span_with_request_id() -> None:
span = trace.get_current_span()
rid = get_request_id()
if span and rid:
span.set_attribute('nori.request_id', rid)
Now every span produced under that request carries the same nori.request_id attribute as your logs, so you can pivot from a slow trace in Jaeger to the matching log lines in Loki/Elasticsearch with one query.
Sentry — set as a tag¶
import sentry_sdk
from core.http.request_id import get_request_id
def tag_sentry_with_request_id() -> None:
rid = get_request_id()
if rid:
sentry_sdk.set_tag('request_id', rid)
Call this from a small middleware that runs after RequestIdMiddleware. Sentry then groups errors by request_id and surfaces it on every event.
Verifying the hook fires¶
Add a debug log inside bootstrap():
def bootstrap() -> None:
import logging
logging.getLogger('nori.bootstrap').warning('bootstrap fired')
# ... rest of your init ...
Run python3 nori.py serve — you should see bootstrap fired before any other startup line. If you do not, the file is in the wrong location (it must be rootsystem/application/bootstrap.py) or the function is not named bootstrap.
When NOT to use the hook¶
- Pure logging configuration (changing levels, adding handlers) belongs in
core.loggerconfiguration via theLOG_LEVEL/LOG_FORMAT/LOG_FILEenvironment variables, not the bootstrap hook. - Database connection pooling is owned by Tortoise ORM via
settings.TORTOISE_ORM. Don't pre-connect insidebootstrap(). - App routes or middleware belong in
routes.pyandasgi.py— the bootstrap hook is for third-party SDK initialization, not application wiring.
If you find yourself reaching for the hook for anything other than instrumentation, the answer is probably one of the dedicated extension points instead.