First see our monitoring doc for an overview what Baserow offers to monitor itself.
This doc explains how to:
.env and set:HONEYCOMB_API_KEY=YOUR_KEY
BASEROW_ENABLE_OTEL=true
just dc-dev restart
Look at the logs of your otel-collector for a starting place:
docker logs baserow-otel-collector-1
docker-compose.dev.yml also launches
an Open Telemetry Collector service
configured by the file in deploy/otel/otel-collector-config.yaml.BASEROW_ENABLE_OTEL=true the dev containers are
configured by the
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 in docker-compose.dev.yml
to send telemetry to that local collector.HONEYCOMB_API_KEY where you can
finally inspect everything.To log something just:
from loguru import logger
def application_code():
logger.info('something')
See Loguru’s docs for more information, it has a ton of awesome features.
Use loguru for useful, structured application logs that help diagnose what happened
without emitting one log for every item in an unbounded operation.
BASEROW_OTEL_LOG_LEVEL can keep verbose local logs while exporting only warning
and error logs through OTLP.Read this first to understand what a trace and span is and why we want them.
You can use the helper decorator baserow_trace to wrap a function
in a span to track its execution time and other attributes:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
class SomeClass:
@baserow_trace(tracer)
def my_func(
self
):
# do expensive operation we want to track how long it takes
pass
@baserow_trace will:
Every DRF request automatically receives one concrete API entry span such as
GridViewView.get below the HTTP server span. Nested functions wrapped by
@baserow_trace then run inside the outermost decorated domain operation, so a handler
calling other decorated handlers does not produce one span per Python call.
Automatic database, Redis, HTTP, and other instrumentation still creates child spans,
so expensive external work remains visible. A small number of important phases, such
as permission evaluation and cache-miss model generation, can use
@baserow_trace(tracer, allow_nested=True). The resulting maximum application-owned
hierarchy is API entry point, domain operation, then important phase; further decorated
calls at the same or a lower semantic level collapse.
Celery auto-instrumentation creates a separate consumer trace for each task execution
and links it to the trace that published the task. The first selected
@baserow_trace domain method becomes the principal operation under that task root;
nested selected handlers collapse in the same way as HTTP operations. Do not add a
second generic span around the whole task body because it duplicates the task root.
Decorate the principal job, export, workflow, or cleanup method instead.
Framework work outside the view is represented by a small number of sibling phases
rather than one span for every middleware. DRFResponse.render shows response
serialization and rendering after the view returns. When Silk is enabled in the
development environment, Silk.persist_profile shows the profiler finalizing and
persisting its own request data. Silk’s internal database writes are suppressed from
dependency instrumentation because the single phase duration is the useful signal.
This distinction matters when a Django request root is much longer than its API view:
the remainder can be framework or development-profiler work rather than application
handler time.
Put @baserow_trace directly on each selected method. The instrumentation then moves
with the method when it is renamed, and adding or renaming an unrelated helper cannot
silently change the trace shape.
from opentelemetry import trace
from baserow.core.telemetry.utils import baserow_trace
tracer = trace.get_tracer(__name__)
class SomeClass:
@baserow_trace(tracer)
def run_operation(self):
pass
@baserow_trace(tracer, allow_nested=True)
def check_permissions(self):
pass
def implementation_helper(self):
# This method is deliberately not traced.
pass
Primary domain handlers can opt in with @baserow_trace_handler. It traces the public
methods declared directly on that handler, while leaving private helpers and explicitly
configured @baserow_trace methods unchanged. Use it on orchestration handlers reached
directly from API views, not on low-level utility classes. Handler-to-handler calls
collapse below the outer handler, and handlers called by an action collapse below the
action, so this also makes read requests useful without duplicating write traces.
from baserow.core.telemetry.utils import baserow_trace_handler
@baserow_trace_handler
class TableHandler:
def get_table(self, table_id):
pass
def _build_queryset(self):
# Private implementation helpers are deliberately not traced.
pass
For an abstract or polymorphic contract such as ActionType or JobType, use
BaserowTraceMeta and decorate the base method. The metaclass propagates that trace
configuration to every override, so concrete implementations do not need another
decorator and their qualified method name is still used for the span.
import abc
from opentelemetry import trace
from baserow.core.telemetry.utils import BaserowTraceMeta, baserow_trace
tracer = trace.get_tracer(__name__)
class ActionType(metaclass=BaserowTraceMeta):
@classmethod
@baserow_trace(tracer)
@abc.abstractmethod
def do(cls):
# Every subclass override of `do` is traced automatically.
pass
Prefer a main handler, action, job, workflow, or expensive query boundary. Do not instrument serializers, constructors, individual signal receivers, low-level helper classes, or every step of internal choreography. Add a nested phase only when it answers a recurring performance question that automatic dependency spans cannot answer.
The standard instrumentation already provides a bounded trace skeleton: concrete DRF
view entry points; DRF authentication, standard permission, and throttling setup;
public methods on selected primary Core, Database, Builder, Automation, and Dashboard
handlers; nested handler calls collapse below their outer operation;
concrete ActionType.do, ActionType.undo, ActionType.redo, and JobType.run
implementations; selected domain phases such as Baserow permission checks and table model
generation; aggregate Baserow signal dispatch; database, Redis, and outbound HTTP calls;
and response rendering. A signal dispatch produces one span for the combined receiver
time, with baserow.signal.receivers identifying the participating hooks. It
deliberately does not produce one span per receiver.
Inbound HTTP requests and Celery tasks each start independently sampled Baserow traces. If either operation was initiated by another trace, its root links to the remote or producer span instead of joining that trace as a child. This keeps tail-sampling lifecycles independent while preserving cross-trace navigation.
Its often very useful to add attributes to the current span so we can filter and query by those later when inspecting the telemetry. We have a simple helper function that lets you do this:
add_baserow_trace_attrs(
attr_you_want_on_the_span=value_you_want,
other_attr=other_value
)
Or you can just use the default OTEL methods:
span = get_current_span()
span.set_attribute(f"baserow.my_span_attr", value)
Remember you can also just manually use the OTEL Python API. The helper functions shown above are just to help you.
You can also keep track of various numerical and statistical metrics using open telemetry. We don’t provide any helper methods as the otel functions are straight-forward. Read this for all of the available types of metrics you can use, but a simple example is shown below:
Important: Any attributes you add to metric will result in a brand-new event being send per periodic metric send for that specific combination of metric and attributes. You must make sure that any attributes added will have only a constant possible number of values and a small number of them. This is to prevent an ever-increasing number of metric events being sent to the server.
For example, below if we called
counter.add(1, {"table_id":table.id})OTEL will send a metric data point for every single table it has seen every single sync resulting in an ever-increasing number of metric events being sent. However, if instead the attribute we added was something like “bulk_created”: True or False this is fine as there are only two possible values.
from opentelemetry import metrics
meter = metrics.get_meter(__name__)
rows_created_counter = meter.create_counter(
"baserow.rows_created",
unit="1",
description="The number of rows created in user tables.",
)
def create_row(table):
# create some row
# keep track of how many have been made!
rows_created_counter.add(
1
)
Use Build OpenTelemetry boards and queries for the supported endpoint and per-user metrics, Honeycomb queries, and retained-trace investigations.
The bundled development collector makes one sampling decision for the complete trace. It prioritizes errors and HTTP/Celery traces carrying their respective slow markers, then uses bounded span throughput for representative normal traces. Queues expected to run for a long time can be excluded from duration-based task marking.
All Baserow processes sending to that collector must use the same always-on SDK sampler:
OTEL_TRACES_SAMPLER=always_on
always_on is the delegate sampling policy for eligible traces. Baserow still rejects
parentless implementation spans at the SDK boundary: only HTTP SERVER spans, Celery
CONSUMER spans, and internal metric observations may start traces. Database, Redis,
outbound HTTP, Silk, handler, and other internal spans remain eligible when a valid
request or task parent exists, but are not exported as isolated one-span traces.
Do not sample Django root spans separately from database, Redis, Celery, or application spans. Per-instrumentation sampling creates root-only and otherwise fragmented traces.
To retain one specific complete trace regardless of the bounded sampling budget, add the explicit escape-hatch query parameter to the backend request:
?force_full_otel_trace=true
The request span is marked with baserow.force_full_otel_trace=true, and the tail
sampler retains the whole trace before evaluating its bounded policies. The one global
SDK sampler also recognizes this query before making its sampling decision. The flag is
not authenticated, so public deployments must restrict or rate-limit it upstream when
unbounded forced traces are not an acceptable cost risk.
Configure the collector’s total span budget and policy allocation in
deploy/otel/otel-collector-config.yaml. When running multiple collectors, divide the
budget between them and use trace-ID-aware routing so every span in a trace reaches the
same tail sampler.
The Collector also forks authenticated HTTP root spans before tail sampling and emits
baserow.http.server.user.request.duration. This histogram has user.id as its only
request dimension; endpoint analytics continue to use
http.server.request.duration. Keep the two families separate so user cardinality is
not multiplied by route, method, and status.
Successful workspace invitation creation and resend operations emit a short-lived
metric observation span. The Collector converts it into the bounded
baserow.workspace.invitation.created.calls counter, dimensioned only by user.id,
then removes the observation span from retained traces.
Completed Celery tasks emit baserow.celery.task.duration with bounded task_name,
queue, and state dimensions. Use its histogram count and distribution for sampling-independent
task volume and latency boards instead of counting sampled task traces.
Database and Redis spans are also forked before trace sampling into sampling-independent,
low-cardinality baserow.dependency.duration histogram. Its observation count and
duration distribution remain accurate when the corresponding trace is not retained.
Only stable database system and operation attributes are dimensions; never add
statements, table IDs, URLs, or user IDs to this metric.
The per-user metric family uses bounded cardinality and reports overflow through
otel.metric.overflow=true. Tune its limit, flush interval, and idle expiration with
the documented BASEROW_OTEL_USER_METRICS_* collector variables, and alert on
overflow.