feat(observability): trace, measure and log Sturnus without shipping content - #50
Merged
Conversation
️✅ 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. 🦉 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
force-pushed
the
feat/otel-finish
branch
from
August 21, 2026 10:36
9f20158 to
09bbccb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) plusinfrastructure/telemetry.pyandtraced.py.Redaction is an allowlist, not a denylist
Unregistered field names are dropped.
bytesis 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=DEBUGthe Discord voicesecret_keyreached the logs, and would have reached Loki. Reproduced:Not a redaction failure —
extra={"secret_key": …}was dropped, and so wasextra={"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. Thatmin()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)plusscrub_eventreducinglogentryto the uninterpolated template. Loki only.discord.voice_stateis 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 aNEVER_ABOVEcounterpart, becauseNEVER_BELOWis applied asmax(level, floor)and can only make a logger quieter: an INFO entry there would have been a no-op at the deployedWARNINGdefault, 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_secondsandseconds_since_progressare observable instruments, not synchronous gauges: a decoder that wedges freezes a gauge, soseconds_since_progresscould 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
modelonly. 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.outcomewas reporting failures as successesIt recorded
donefor every failed job, becauseprocess_onereturnsTrueafterqueue.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_ABOVEis 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 -> boolwidens an application port for an observability reason. Genuinely needed to tell permanent loss from a retry, but it is a contract change.sturnus.queue.depthis 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.