Tracing¶
What the bot, the reminder and the web app record as OpenTelemetry traces, what they don't, and how to add more. Turning tracing on is covered in Configuration: it's off unless an OTLP endpoint is set.
What gets traced¶
Every unit of work gets one root span; everything it does — Redis calls, events from the repetition engine — hangs under it.
| Span | Process | Attributes |
|---|---|---|
telegram.message |
bot | telegram.chat_id; telegram.command — the first word, e.g. /wiederholen — if the text starts with / |
telegram.callback_query |
bot | telegram.callback_data; telegram.chat_id, unless the button belongs to an old message Telegram no longer returns |
telegram.my_chat_member |
bot | telegram.chat_id, telegram.chat_member.status (kicked when the learner blocks the bot, member when they unblock it) |
telegram.update |
bot | none — any other kind of update |
reminder.tick |
reminder | none — one sweep over all chats |
reminder.check_chat |
reminder | telegram.chat_id; reminder.sent — the reminder went out; reminder.bot_blocked — the chat was skipped because the learner had blocked the bot, or Telegram refused the message for that reason. Nested in reminder.tick |
| an HTTP server span per request | web | the usual HTTP attributes, from Litestar's OpenTelemetry plugin; for POST /api/client-error, also client.error.step and client.error.status |
| a span per Redis command | all three | from opentelemetry-instrumentation-redis |
The web widget reports its own failures to POST /api/client-error — the step that failed and, if there was one, the HTTP status — so browser-side errors can be queried next to everything else.
Text typed by learners is never recorded. A message span carries only the command, and callback data is always a value the bot generated itself: a button id, sometimes with a topic group id or a reminder interval appended (__topic_group__:verbs, __remind_after__:8).
An exception raised inside a span is recorded on it automatically. In the reminder, a failing chat is recorded on its reminder.check_chat span before the sweep catches the error, logs it and moves on to the next chat.
Events from the repetition engine¶
Tutor adds span events to whatever span is current — the update's span in the bot, the request's span in the web app:
| Event | When | Attributes |
|---|---|---|
ExerciseAnswered |
every check_answer() |
word, topic, is_correct, is_new, recall_mode (none, optional, required), prev_repetition_interval (absent on a pair's first answer), next_repetition_interval |
TopicUnlocked |
a linked topic is scheduled for a word (see Course format) | source_topic, dependent_topic, via (chain or gate) |
NoExerciseAvailable |
next_exercise() finds nothing to show |
reason: nothing_available, or daily_cap_reached when only new words are left and today's limit is used up |
Recalls produce no events: request_recall() and check_recall() don't emit anything.
What isn't traced¶
- Outgoing HTTP calls. Only Redis and incoming web requests are instrumented. Calls to the Telegram Bot API, Anthropic, R2 and Workers AI don't appear as spans; time spent in them shows up as a gap inside the parent span.
- The cue generator. It doesn't call
configure_tracing(). - Bot startup. Setting the bot's commands and profile happens before polling starts, outside any span.
How it's wired¶
wiederholen.tracing holds the setup shared by all three processes:
configure_tracing()installs aTracerProviderexporting over OTLP/HTTP — or does nothing if neitherOTEL_EXPORTER_OTLP_ENDPOINTnorOTEL_EXPORTER_OTLP_TRACES_ENDPOINTis set. The resource gets no attributes from code: the service name and the rest come fromOTEL_SERVICE_NAMEandOTEL_RESOURCE_ATTRIBUTES. A hardcoded fallback service name was tried and dropped: it hid a process missing itsOTEL_*variables under another process's name, while without one it shows up asunknown_service.instrument_redis()patches the Redis client globally, so every store's commands are traced without changes at the call sites. Calling it again only logs a warning.default_traceris obtained at import time. Until a provider is installed it produces no-op spans, and it starts recording onceconfigure_tracing()installs one.
The main() of the bot, the reminder and the web app call configure_tracing() and instrument_redis(). Nothing calls them at import time, so importing a package — in tests, say — never sets up exporting.
Root spans per process¶
Bot. TracingMiddleware opens a span per update.
It must be registered before aiogram's FSM middleware. In
wiederholen/bot/__init__.pythe dispatcher is created withDispatcher(disable_fsm=True), which stops aiogram from registering its FSM middleware itself, and the two are registered by hand ondispatcher.update: tracing first,dispatcher.fsmsecond. The FSM middleware reads the chat's state from Redis before the update is routed, and middlewares wrap in registration order — with tracing second, that read would show up as a separate root span instead of part of the update.
Reminder. reminder.py opens its spans by hand: reminder.tick for each sweep, reminder.check_chat for each chat.
Web. create_app() registers Litestar's OpenTelemetryPlugin, which opens a span per request.
Without these, every Redis call would be a root span of its own.
Repetition engine: emit()¶
wiederholen.school.tutoring.events defines the three events as frozen dataclasses, and emit(event) adds one to trace.get_current_span(), named after the dataclass. Before that, _to_otel_attributes() turns the dataclass into span attributes: enum values are replaced by their .value, and None fields are left out, since neither is a valid attribute value. Events go onto the span rather than being returned, and are dataclasses rather than dict literals (see ADR 0002).
This makes tutoring depend on the OpenTelemetry API — but only the API, which is a safe no-op without a configured provider. Tutor's tests don't need any tracing setup.
The events are internal to tutoring: only RecallMode is exported, since the bot needs it for its recall flow.
Adding a span or attribute¶
- Get the tracer from
wiederholen.tracing:default_tracer.start_as_current_span(...). Don't create a tracer of your own. - Name spans
area.action, likereminder.check_chat, and put attributes in a namespace:telegram.*,reminder.*,client.error.*. - Prefer an attribute on the current span (
trace.get_current_span().set_attribute(...)) over a new span when you only want to record a fact, as/api/client-errordoes. - For something
Tutordecides, add an event: a frozen dataclass inevents.py, emitted withemit()where the decision is made. - Never record text a learner typed.
- Test it with the
tracerandexporterfixtures (see below), checking the span's attributes or events.
Testing¶
- Nothing is exported from tests.
tests/plugins/tracing.py::pytest_configuresetsOTEL_SDK_DISABLED=truefor the whole session.docker compose run --rm bot pytestshares thebotservice's configuration, so without it, a test run with real exporter settings would send spans to the real backend. - Span content is checked against an in-memory exporter. The shared
tracerandexporterfixtures build a recordingTracerProviderwith anInMemorySpanExporter, re-enabling the SDK just for that provider.tests/bot/test_tracing.pyuses them forTracingMiddleware, andtests/school/tutoring/test_tutoring_events.pyfor the events, by runningcheck_answer()ornext_exercise()insidetracer.start_as_current_span(...)and readingspan.events. - The global provider is never used in tests.
trace.set_tracer_provider()works only once per process, and later calls are silently ignored, so tests construct their providers directly.configure_tracing()andinstrument_redis()themselves are tested intests/test_tracing.py.
Local setup¶
To send traces from a local run, put the OTEL_* variables in a gitignored compose.override.yaml next to compose.yaml (see Contributing). Compose merges environment per service, so each of bot, reminder and web needs its own block, with its own OTEL_SERVICE_NAME.
A stray = in a key, like OTEL_RESOURCE_ATTRIBUTES=: ..., isn't an error in Compose: the = silently ends up in the value. For OTEL_RESOURCE_ATTRIBUTES that shows up as an invalid key warning from OpenTelemetry. docker compose config shows what the variables actually resolve to.
Decisions¶
- ADR 0002: the repetition engine reports events on the current span.