OpenTelemetry¶
Generated apps can emit OpenTelemetry traces, metrics, and (optionally)
logs. Telemetry is fully opt-in: when the project config does not
set telemetry, the generated tree contains zero references to OTel
and the runtime cost is exactly zero.
Enabling¶
Add a telemetry block to your project config:
local telemetry = import 'be/telemetry/telemetry.libsonnet';
{
databases: [...],
apps: [...],
telemetry: telemetry.otel('my-service-name', {
sampler: 'parentbased_traceidratio',
sampler_ratio: 0.1,
resource_attributes: { team: 'platform' },
}),
}
service_name is required; everything else has a sensible default.
The full schema lives in be.config.schema.TelemetryConfig.
After regenerating, install the pinned OTel package set via the
opentelemetry extra:
pip install kiln-generator[opentelemetry]
Generated apps already depend on kiln-generator (they import from
ingot), so the extra is the single source of truth for OTel
versions – nothing extra to vendor or copy.
Then call init_telemetry from your app entry point, before mounting
the generated router:
from fastapi import FastAPI
from _generated.routes import router
from _generated.telemetry import init_telemetry
app = FastAPI()
init_telemetry(app)
app.include_router(router)
Deployment environment¶
The deployment.environment.name resource attribute (dev / staging /
prod) is intentionally not a code-gen argument: the same artifact
should ship across environments. It’s read from the env var named by
environment_env at startup (default: ENVIRONMENT):
ENVIRONMENT=prod ./run-app
Override the variable name if your deployment already exports a different one:
telemetry.otel('my-service-name', {
environment_env: 'DEPLOY_ENV',
})
When the variable is unset (or empty), the attribute is omitted.
What you get¶
Signal |
Source |
Span / metric name |
|---|---|---|
HTTP server span |
|
one per request |
Internal handler span |
|
|
DB client span |
|
per query |
Outbound HTTP ( |
|
opt-in via |
Outbound HTTP ( |
|
opt-in via |
Metrics |
OTLP |
wired; user code emits |
Internal handler spans carry low-cardinality attributes for filtering:
be.resource— e.g."article"be.op— e.g."get"for CRUD,"publish"for actions
Both CRUD ops and user-defined actions go through the same
@traced_handler decorator and the same be.op attribute — the
value discriminates (be’s CRUD names are a fixed small set;
anything else is a user-defined action).
Sampler defaults¶
The default sampler is parentbased_always_on: friendly for
development, expensive in production. Production deployments
typically switch to:
sampler: 'parentbased_traceidratio',
sampler_ratio: 0.05,
Sampling at 5% with parent-based propagation gives you full traces for sampled requests while keeping ingest volume manageable.
Exporter¶
By default the generated init_telemetry does not pin a transport –
it instantiates the OTLP HTTP exporter with no arguments, and the OTel
SDK reads the standard environment variables itself at construct time:
OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer abc123
This keeps the same artifact deployable across environments. Override
with exporter: 'otlp_grpc' | 'console' | 'none' if you want to pin
a specific transport at code-generation time.
There are no kiln-side knobs for the env-var names — point your deployment at the standard OTel ones.
otlp_grpc is additive: install it alongside the base extra,
pip install 'kiln-generator[opentelemetry,opentelemetry-grpc]'
The gRPC exporter lives in its own extra because it pulls in protobuf and grpc-io – roughly an order of magnitude heavier than the HTTP transport. Generated code imports the gRPC exporter lazily, so apps that stay on OTLP/HTTP never load the gRPC stack even when both extras are present.
Per-resource and per-op opt-out¶
The project-level span_per_handler / span_per_action toggles
control tracing globally. Hot-path or low-value resources can opt out
without disabling telemetry overall:
{
model: 'health.models.Probe',
trace: false, // skip spans for every op on this resource
operations: [
{ name: 'get' },
],
}
The same field works per-operation:
{ name: 'list', trace: false } // skip the spans for this op only
The HTTP server span from FastAPIInstrumentor is unaffected by these
overrides – they only suppress be’s internal handler/action span
and its be.resource / be.op attributes.
PII and the auth router¶
capture_request_body and capture_response_body default to
off because request and response payloads commonly contain PII.
Even when they are turned on, the generated auth router
(auth/router.py) explicitly scrubs:
http.request.body,http.response.bodyhttp.request.header.authorizationhttp.request.header.cookie,http.response.header.set-cookie
with a [scrubbed] placeholder via
scrub_current_span_attributes(...). A placeholder rather than
attribute removal so a “missing http.request.body” alert doesn’t
mask a real outage.
Logging¶
be does not generate logging calls in CRUD handlers – you emit logs, and the two telemetry knobs below decide what happens to them. Both are off by default.
Library assumption¶
be assumes the stdlib logging module. Loguru and
structlog both interoperate with stdlib (loguru via
InterceptHandler, structlog via LoggerFactory(stdlib=True));
set them up that way and the rest of this section applies unchanged.
instrument_logging: trace correlation¶
instrument_logging: true,
Wires opentelemetry.instrumentation.logging.LoggingInstrumentor,
which patches logging.LogRecord so every record carries
otelTraceID, otelSpanID, otelTraceSampled, and
otelServiceName. The default log format string is also updated to
include them. This does not export logs anywhere – it only adds
trace IDs to whatever sink you’re already using (stdout, file, syslog,
etc.). Use it when your logs go to a different backend than your
traces and you want to jump between them.
logs: OTLP log export¶
logs: true,
Builds an opentelemetry.sdk._logs.LoggerProvider, installs it
globally, and attaches an opentelemetry.sdk._logs.LoggingHandler to
the stdlib root logger at level NOTSET so every record routes
through OTLP alongside your traces and metrics. The handler runs
in addition to your existing handlers – print-style stdout logs
keep working; OTLP just becomes another sink.
Two consequences worth knowing:
The root logger needs a level set somewhere (
logging.basicConfig, uvicorn’s logging config, etc.) for any record to actually reach the handler.NOTSETdefers to the loggers’ levels, it doesn’t override them upward.The OTel logs SDK API is the youngest of the three signals and is most likely to churn between OTel releases. Pin versions tightly (the
[opentelemetry]extra already does this) and re-test on upgrade.
Combining¶
Most teams that turn on either knob want both:
logs: true,
instrument_logging: true,
Records flow:
your code
-> logging.getLogger().info(...)
-> LoggingInstrumentor adds trace IDs to the record
-> root logger handlers (stdout, etc.) fire
-> OTLP LoggingHandler fires, ships to the collector
Emitting your own signals¶
init_telemetry installs the global tracer, meter, and logger
providers — anything in your code can ask the OTel API for them and
start emitting. No kiln-side wiring required.
Custom traces¶
Get a tracer once at module level; start spans where you need them.
Spans nest automatically under whatever’s active, so a span started
inside a handler ends up under that handler’s @traced_handler
span:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
async def publish_article(article, db, body):
with tracer.start_as_current_span("render_markdown") as span:
span.set_attribute("article.length", len(body.content))
rendered = render(body.content)
# rest of the action…
Span attributes are per-span, so high-cardinality values like
article.id are fine here. Don’t put them on metric attributes
(see below).
Custom metrics¶
Get a meter once, register an instrument once at module level, record on it from anywhere. Counters, histograms, up-down counters, and observable gauges are all available:
from opentelemetry import metrics
meter = metrics.get_meter(__name__)
published_total = meter.create_counter(
"blog.articles.published",
unit="1",
description="Articles successfully published.",
)
publish_latency = meter.create_histogram(
"blog.articles.publish_duration",
unit="ms",
description="Time spent in the publish action.",
)
async def publish_article(article, db, body):
started = time.monotonic()
# …work…
published_total.add(1, {"author_type": article.author.type})
publish_latency.record(
(time.monotonic() - started) * 1000,
{"author_type": article.author.type},
)
Cardinality matters here. Metric attributes are dimensions —
each unique combination is a separate time series at the backend.
Use small enumerations (author_type ∈ {staff, guest}), never
per-row identifiers (article.id). Put the high-cardinality stuff
on a span attribute instead.
Custom logs¶
Use stdlib logging. With instrument_logging=True, every
record gets otelTraceID / otelSpanID injected; with
logs=True, every record also ships over OTLP via the handler
be attaches to the root logger. You don’t need to import anything
OTel-specific:
import logging
logger = logging.getLogger(__name__)
async def publish_article(article, db, body):
logger.info(
"publishing article",
extra={"article_id": str(article.id), "kind": body.kind},
)
If you use loguru or structlog, route them through stdlib (loguru’s
InterceptHandler, structlog’s LoggerFactory(stdlib=True)) and
the two toggles still work unchanged.
Naming and gotchas¶
Tracer / meter / logger names are conventionally
__name__. They populate the instrumentation scope facet at the backend — keep them stable so dashboards stay readable.Resource attributes vs span/metric attributes. Resource attributes (set once at
init_telemetry, e.g.service.name,team) describe the service; span and metric attributes describe an event. Don’t repeat resource attributes per span.Imports. Use
from opentelemetry import metrics(notimport opentelemetry.metrics) — the SDK assumes the former import shape for some of its internal lazy-loading.
Pinned versions¶
The kiln-generator[opentelemetry] extra pins the OTel packages to
a coherent release pair:
opentelemetry-api==1.29.0
opentelemetry-sdk==1.29.0
opentelemetry-exporter-otlp-proto-http==1.29.0
opentelemetry-instrumentation-fastapi==0.50b0
opentelemetry-instrumentation-sqlalchemy==0.50b0
opentelemetry-instrumentation-requests==0.50b0
The optional kiln-generator[opentelemetry-grpc] extra adds:
opentelemetry-exporter-otlp-proto-grpc==1.29.0
The instrumentation packages ride a separate 0.x.b version line
that stabilises later than the core SDK; bump core (1.x) and
instrumentation (0.x.b) in lockstep when upgrading.