Skip to content

feat(observability): trace, measure and log Sturnus without shipping content - #50

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/otel-finish
Aug 21, 2026
Merged

feat(observability): trace, measure and log Sturnus without shipping content#50
TheMeinerLP merged 1 commit into
mainfrom
feat/otel-finish

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

OpenTelemetry traces and metrics, plus structured logging shaped for Loki. Large — about 6,500 lines across 45 files, introducing sturnus/observability/ (setup, redaction, fields, events) plus infrastructure/telemetry.py and traced.py.

Redaction is an allowlist, not a denylist

Unregistered field names are dropped. bytes is dropped as a class — audio and wrapped data keys are always bytes, so the highest-value leak is closed by construction rather than by remembering. Strings are pattern-scrubbed and capped, and every replacement is visible («redacted:discord_token») rather than silent.

The leak this branch had, and how it was closed

With STURNUS_LOG_LEVEL=DEBUG the Discord voice secret_key reached the logs, and would have reached Loki. Reproduced:

discord/ext/voice_recv/gateway.py:57
  log.debug("Received op %s: \n%s", op, pformat(data))
  → {'mode': …, 'secret_key': [1, 2, …, 32], 'ssrc': …}

Not a redaction failureextra={"secret_key": …} was dropped, and so was extra={"voice_ready": {…}}. The key arrived already formatted into a third-party logger's message string, which a field allowlist cannot touch.

The cause was root.setLevel(min(resolved_level, resolved_third_party)), which put 28 unclamped third-party loggers at DEBUG. That min() was never load-bearing: Python checks the originating logger's effective level during propagation, never root's — verified by pinning root to WARNING and watching Sturnus' DEBUG still emit while third-party DEBUG stayed suppressed. Removing it costs nothing and closes the hole.

Sentry was never affected: LoggingIntegration(level=None, event_level=ERROR) plus scrub_event reducing logentry to the uninterpolated template. Loki only.

discord.voice_state is pinned at INFO, not silenced. DEBUG is where the leak lives; INFO is the connect narrative — handshake attempts, endpoint, close codes, resume — and that is the evidence base for the capture-failure cooldown. Pinning required a NEVER_ABOVE counterpart, because NEVER_BELOW is applied as max(level, floor) and can only make a logger quieter: an INFO entry there would have been a no-op at the deployed WARNING default, silently leaving the narrative deleted.

Metrics that answer questions this project actually had

sturnus.transcription.decoded_seconds ÷ wall time is the real-time factor. A job that "finished" 100 minutes of audio in 43 seconds reports 140× — impossible, and unmistakable. The symptom everyone actually saw, an empty transcript, looked exactly like a participant who never spoke, and was read as one for a day.

position_seconds, total_seconds and seconds_since_progress are observable instruments, not synchronous gauges: a decoder that wedges freezes a gauge, so seconds_since_progress could never grow — which is the whole alert. They emit no observation when idle, so a series goes stale rather than reporting a finished job forever. The stall clock starts before the library call, because the collapse happened inside feature extraction.

Labels are model only. No session, job, guild or user id: unbounded cardinality, and a privacy leak with a very long retention.

The progress signal costs nothing — transcribe() already returns a lazy generator, and the old tuple comprehension consumed it in one expression, discarding every intermediate observation.

sturnus.job.outcome was reporting failures as successes

It recorded done for every failed job, because process_one returns True after queue.fail — it means "work was attempted". A metric that reports failures as successes is worse than none, because it will be believed. The outcome is now taken at the transition, from what happened.

Verification

777 tests pass, mypy and ruff clean. 28 mutations, all killed — including "back to a tuple comprehension", "begin() after the model call", "no top-up", "a session id becomes a metric label", and "a retry counted as done".

Squashed to one commit: two earlier ones carried a fixture with the literal shape of a Discord bot token, which GitHub's push protection blocks — correctly, whether or not that string opens anything. It is now assembled at import, identical at runtime, so the tests still prove the redaction catches that exact shape with nothing in the file for a scanner or a reader to mistake for a credential.

Worth second-guessing

  • NEVER_ABOVE is new mechanism the review did not ask for. Without it the finding is unmet in production; with it there is a second dict that can hold a logger open. One entry, both directions tested.
  • Queue.fail -> bool widens an application port for an observability reason. Genuinely needed to tell permanent loss from a retry, but it is a contract change.
  • sturnus.queue.depth is sampled per poll, and the worker does not poll while transcribing — during a 98-minute job the reading is 98 minutes old. Documented, not fixed; fixing it needs a DB read from the exporter thread.

@gitguardian

gitguardian Bot commented Aug 21, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

…content

OpenTelemetry traces and metrics, plus structured logging shaped for Loki.

Squashed to a single commit on top of main. Two earlier commits on this
branch carried fixtures with the literal shape of a credential -- an AWS
access key id and a Discord bot token -- which secret scanning detects,
correctly, whether or not either string opens anything. Both are now
assembled from parts at import: identical at runtime, so the tests still
prove the redaction catches those exact shapes, with nothing in the file
for a scanner or a reader to mistake for a credential. Rewriting the tip
alone would not have helped; a scan reads every commit in the pull
request, so the literals had to leave the history.

**Redaction is an allowlist, not a denylist.** Unregistered field names are
dropped, `bytes` is dropped as a class (audio and wrapped data keys are
always bytes, so that closes the highest-value leak by construction),
strings are pattern-scrubbed and capped, and every replacement is visible
(`«redacted:discord_token»`) rather than silent.

**The leak this branch closed.** With `STURNUS_LOG_LEVEL=DEBUG` the Discord
voice `secret_key` reached the logs and would have reached Loki.
Reproduced:

    discord/ext/voice_recv/gateway.py:57
      log.debug("Received op %s: \n%s", op, pformat(data))
      -> {'mode': ..., 'secret_key': [1, 2, ..., 32], 'ssrc': ...}

Not a redaction failure: `extra={"secret_key": ...}` was dropped, and so was
`extra={"voice_ready": {...}}`. The key arrived already formatted into a
third-party logger's *message string*, which a field allowlist cannot
touch. The cause was `root.setLevel(min(resolved_level,
resolved_third_party))`, which put 28 unclamped third-party loggers at
DEBUG -- every logger absent from the enumerated clamp list inherits from
root, and one list cannot be complete about libraries it does not import.
That `min()` was never load-bearing (Python checks the *originating*
logger's effective level on propagation, never root's), so removing it
costs nothing and closes the hole. `THIRD_PARTY_FLOOR` replaces the
enumeration as the structural half of the fix, and
`tests/observability/test_third_party_log_floor.py` asserts the property
over `logging.Logger.manager.loggerDict` rather than over a list.

`discord.voice_state` is pinned at INFO rather than silenced: DEBUG is where
the leak lives, but INFO is the connect narrative -- handshake attempts,
endpoint, close codes, resume -- and it is the evidence base for telling
the three capture failures apart. Pinning needed a `NEVER_ABOVE`
counterpart: `NEVER_BELOW` is applied as `max(level, floor)` and can only
ever make a logger *quieter*, so an INFO entry there was a no-op at the
deployed `WARNING` default -- the logger still ended at WARNING and the
line the entry was written to keep was still gone.

**The metrics answer questions this project actually had.**
`sturnus.transcription.decoded_seconds` divided by wall time is the
real-time factor: a job that "finished" a 100-minute recording in 43
seconds reports an impossible, unmistakable number -- where the symptom
everyone saw, an empty transcript, looked exactly like a participant who
never spoke and was misread as one for a day. `position_seconds`,
`total_seconds` and `seconds_since_progress` are **observable instruments,
not synchronous gauges**: a gauge only changes when a call site sets it, so
a decoder that wedges freezes it and `seconds_since_progress` -- the actual
alert -- could never grow. The SDK calls these callbacks once per export
interval instead, which is also what lets them emit nothing at all while
the worker is idle, so the series goes stale rather than reporting a
finished job's numbers forever. The stall clock starts before the library
call, since the collapse happened inside feature extraction. Labels are
`model` only: no session, job, guild or user id, which would be unbounded
cardinality and a record of who was in a voice channel when, kept for as
long as the metric store keeps anything.

`sturnus.job.outcome` reported `done` for every failed job, because
`process_one` returns True after `queue.fail(...)` exactly as it does after
`queue.complete(...)` -- the boolean means "work was attempted", never
"work succeeded", and a metric that reports failures as successes is worse
than no metric because it will be believed. The label is now recorded by
the transitions that decide a job's terminal state, and `crashed` is the
one the worker loop still owns.

**Rebased onto #48, which rewrote the same method.** The transcription
mechanics are main's: the model is handed the gated speech concatenated,
`clip_timestamps` is re-expressed on that timeline, and every returned
segment goes back onto the recording's through
`_on_the_original_timeline(segment.start, segment.end, segment.seek, ...)`,
where `Segment.seek` -- the encoder window the segment was decoded from --
is what names its clip. The observability is re-expressed on top: the
segment generator is drained by a loop and not a comprehension, so
`TRANSCRIPTION_PROGRESS` sees each segment as it arrives rather than only
after the job has already finished.

Progress is reported on the *concatenated* timeline -- `advance(segment.end)`
and not the restored end -- because the denominator is
`duration_after_vad`, which since #48 is the concatenated speech. Reporting
a restored end against it would put a job that had decoded its first clip
at several hundred percent. `telemetry.TranscriptionProgress` and
`docs/operations.md` § 7.5 say so; they described the whole file before.

Two call sites arrived from main that the merge could not have seen, both
of them what `tests/test_logging_discipline.py` R2 and R6 forbid, and for
the reason R6 exists: `%s` on an exception prints `str(exc)` verbatim into
the message `observability.scrub_event` forwards to Sentry.
`RecordingService._report_silent_audio` (#48) was three `log.warning`
calls, two interpolating an exception and one passing `display_name`; it is
now `speaker.audio_silent`, `speaker.silent_warning_failed` and
`speaker.silent_record_failed` through `log_event`/`log_exception`, with
`display_name` gone -- the channel message renders the mention, and the
operator has the id. `RequeueConfirmView._disable` (#49) is now
`queue.view_disable_failed` the same way.
@TheMeinerLP
TheMeinerLP merged commit 1ff8a9d into main Aug 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant