Skip to content

Commit 5aa674e

Browse files
authored
feat(telemetry): instrument processing lifecycle (#11540)
## Summary - add accepted/terminal transcript lifecycle telemetry around the live STT authority - record speaker proposals and durable speaker-assignment confirmations without person names or transcript content - link extracted tasks to their source conversation and record bounded assignee corrections - measure authoritative decoded-audio duration, server-VAD speech duration, and completed diarization outcomes Processing-cost telemetry is intentionally excluded because this path does not expose a reliable authoritative cost value. ## Verification - `backend/test.sh` over the eight changed unit files plus the receiver Opus regression file: passed - `python scripts/scan_async_blockers.py --dirs routers utils`: passed - `make preflight` against current `main` with this PR body: passed (24 diff-scoped checks) The live provider WebSocket path was not exercised locally; lifecycle behavior was verified through the production receiver/runtime seams with injected telemetry clients. Line-Count-Exception: backend/routers/conversations.py | 1538 -> 1605 | speaker confirmation must emit after each authoritative durable assignment mutation already owned by this router Line-Count-Exception: backend/utils/conversations/process_conversation.py | 2204 -> 2225 | task extraction telemetry stays at the existing canonical and legacy persistence boundary ## Product invariants affected none ## Failure class (fixes) Failure-Class: none <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/11540?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents 920fc55 + af0c22d commit 5aa674e

18 files changed

Lines changed: 726 additions & 11 deletions

backend/routers/action_items.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
PendingSyncResponse,
4444
)
4545
from utils.task_intelligence import task_links
46+
from utils.product_telemetry import emit_product_event
4647

4748
router = APIRouter()
4849

@@ -486,6 +487,25 @@ def update_action_item(
486487
updated_item = action_items_db.get_action_item(uid, action_item_id)
487488
if updated_item is None:
488489
raise HTTPException(status_code=500, detail="Updated action item could not be loaded")
490+
491+
if request.owner is not None:
492+
previous_owner_value = existing_item.get('owner') or 'unknown'
493+
previous_owner = getattr(previous_owner_value, 'value', str(previous_owner_value))
494+
next_owner = request.owner.value
495+
if previous_owner != next_owner:
496+
emit_product_event(
497+
uid=uid,
498+
event='Task Assignee Corrected',
499+
properties={
500+
'action_item_id': action_item_id,
501+
'conversation_id': updated_item.get('conversation_id'),
502+
'previous_assignee': (
503+
previous_owner if previous_owner in {'user', 'other', 'unknown'} else 'unknown'
504+
),
505+
'new_assignee': next_owner,
506+
'field_changed': 'owner',
507+
},
508+
)
489509
_wake_task_changes(uid, [action_item_id], updated_item.get('updated_at'))
490510

491511
# Reconcile the client-scheduled reminder when completion or due date changed, using the final

backend/routers/conversations.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import hashlib
23

34
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
45
from typing import Any, Dict, List, Optional
@@ -65,6 +66,7 @@
6566
from utils.other.storage import get_conversation_recording_if_exists
6667
from utils.app_integrations import trigger_external_integrations
6768
from utils.request_validation import NonNegativeOffset, PositiveLimit
69+
from utils.product_telemetry import emit_product_event
6870
from utils.conversations.calendar_linking import (
6971
get_overlapping_calendar_event,
7072
write_conversation_link_to_calendar_event,
@@ -91,6 +93,45 @@ def _get_valid_conversation_by_id(uid: str, conversation_id: str) -> dict:
9193
return conversation
9294

9395

96+
def _speaker_assignment(segment: TranscriptSegment) -> str:
97+
if segment.is_user:
98+
return 'self'
99+
if segment.person_id:
100+
return f"person:{hashlib.sha256(str(segment.person_id).encode('utf-8')).hexdigest()[:16]}"
101+
return 'unassigned'
102+
103+
104+
def _speaker_assignment_kind(assignment: str) -> str:
105+
return 'person' if assignment.startswith('person:') else assignment
106+
107+
108+
def _emit_speaker_identity_confirmed(
109+
*,
110+
uid: str,
111+
conversation_id: str,
112+
scope: str,
113+
before: List[str],
114+
after: List[str],
115+
) -> None:
116+
if not after:
117+
return
118+
assignment_kinds = [_speaker_assignment_kind(value) for value in after]
119+
properties = {
120+
'conversation_id': conversation_id,
121+
'confirmation': 'accepted' if before == after else 'corrected',
122+
'assignment': assignment_kinds[0] if len(set(assignment_kinds)) == 1 else 'mixed',
123+
'scope': scope,
124+
'affected_segment_count': len(after),
125+
}
126+
if len(set(after)) == 1 and assignment_kinds[0] == 'person':
127+
properties['assignment_id'] = after[0]
128+
emit_product_event(
129+
uid=uid,
130+
event='Speaker Identity Confirmed',
131+
properties=properties,
132+
)
133+
134+
94135
def _enrich_deferred_conversation(uid: str, conversation: dict) -> dict:
95136
"""First open of a lazily-deferred desktop conversation. The LLM enrichment (summary, action
96137
items, memories, embeddings, app results) takes ~10s, so we run it in the BACKGROUND and return
@@ -1021,6 +1062,7 @@ def set_assignee_conversation_segment(
10211062

10221063
is_unassigning = value is None or value is False
10231064

1065+
before = [_speaker_assignment(conversation.transcript_segments[segment_idx])]
10241066
if assign_type == 'is_user':
10251067
conversation.transcript_segments[segment_idx].is_user = bool(value) if value is not None else False
10261068
conversation.transcript_segments[segment_idx].person_id = None
@@ -1034,6 +1076,13 @@ def set_assignee_conversation_segment(
10341076
conversations_db.update_conversation_segments(
10351077
uid, conversation_id, [segment.model_dump() for segment in conversation.transcript_segments]
10361078
)
1079+
_emit_speaker_identity_confirmed(
1080+
uid=uid,
1081+
conversation_id=conversation_id,
1082+
scope='segment',
1083+
before=before,
1084+
after=[_speaker_assignment(conversation.transcript_segments[segment_idx])],
1085+
)
10371086
# thinh's note: disabled for now
10381087
# segment_words = len(conversation.transcript_segments[segment_idx].text.split(' '))
10391088
# # TODO: can do this async
@@ -1090,6 +1139,9 @@ def set_assignee_conversation_segment(
10901139

10911140
is_unassigning = value is None or value is False
10921141

1142+
targeted_segments = [segment for segment in conversation.transcript_segments if segment.speaker_id == speaker_id]
1143+
before = [_speaker_assignment(segment) for segment in targeted_segments]
1144+
10931145
if assign_type == 'is_user':
10941146
for segment in conversation.transcript_segments:
10951147
if segment.speaker_id == speaker_id:
@@ -1108,6 +1160,13 @@ def set_assignee_conversation_segment(
11081160
conversations_db.update_conversation_segments(
11091161
uid, conversation_id, [segment.model_dump() for segment in conversation.transcript_segments]
11101162
)
1163+
_emit_speaker_identity_confirmed(
1164+
uid=uid,
1165+
conversation_id=conversation_id,
1166+
scope='speaker',
1167+
before=before,
1168+
after=[_speaker_assignment(segment) for segment in targeted_segments],
1169+
)
11111170
# This will be used when we setup recording for conversations, not used for now
11121171
# get the segment with the most words with the speaker_id
11131172
# segment_idx = 0
@@ -1153,6 +1212,7 @@ def assign_segments_bulk(
11531212

11541213
segment_indices = _resolve_bulk_segment_indices(conversation, data.segment_ids)
11551214
resolved_segment_ids = [conversation.transcript_segments[index].id for index in segment_indices]
1215+
before = [_speaker_assignment(conversation.transcript_segments[index]) for index in segment_indices]
11561216

11571217
for index in segment_indices:
11581218
segment = conversation.transcript_segments[index]
@@ -1166,6 +1226,13 @@ def assign_segments_bulk(
11661226
conversations_db.update_conversation_segments(
11671227
uid, conversation_id, [segment.model_dump() for segment in conversation.transcript_segments]
11681228
)
1229+
_emit_speaker_identity_confirmed(
1230+
uid=uid,
1231+
conversation_id=conversation_id,
1232+
scope='bulk',
1233+
before=before,
1234+
after=[_speaker_assignment(conversation.transcript_segments[index]) for index in segment_indices],
1235+
)
11691236

11701237
# Trigger speaker sample extraction when assigning to a person
11711238
if data.assign_type == 'person_id' and value:

backend/routers/listen/receiver.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from utils.log_sanitizer import sanitize
7171
from utils.listen_audio import ChannelConfig, mix_n_channel_buffers, resample_pcm
7272
from utils.observability.fallback import record_fallback
73+
from utils.product_telemetry import emit_product_event
7374

7475
logger = logging.getLogger(__name__)
7576

@@ -516,11 +517,11 @@ async def _flush_stt_buffer(self, buffer: bytearray, *, force: bool = False) ->
516517
self._capture('capture_outbound_stt', outbound_audio)
517518
self.host.state.dg_usage_ms_pending += decision.dg_usage_ms
518519

519-
async def _handle_multi_channel_audio(self, data: bytes) -> None:
520+
async def _handle_multi_channel_audio(self, data: bytes) -> int:
520521
request = self.host.request
521522
channel_index = self.channel_id_to_index.get(data[0])
522523
if channel_index is None:
523-
return
524+
return 0
524525
audio = data[1:]
525526
if request.codec == 'opus' and self.multi_opus_decoders[channel_index]:
526527
try:
@@ -529,10 +530,10 @@ async def _handle_multi_channel_audio(self, data: bytes) -> None:
529530
)
530531
except Exception as error:
531532
self._record_decode_failure('opus', error, len(audio), channel=channel_index)
532-
return
533+
return 0
533534
self.decode_failure_streak = 0
534535
if not audio:
535-
return
536+
return 0
536537
pcm = resample_pcm(bytes(audio), request.sample_rate, TARGET_SAMPLE_RATE)
537538
self._capture('capture_client_audio', pcm)
538539
# Custom-STT clients own transcript production. Their channel sockets are intentionally
@@ -575,6 +576,8 @@ async def _handle_multi_channel_audio(self, data: bytes) -> None:
575576
for buffer in self.channel_mix_buffers:
576577
del buffer[: decision.min_len]
577578

579+
return len(audio)
580+
578581
async def _handle_text(self, message: str) -> None:
579582
try:
580583
loaded = json.loads(message)
@@ -649,6 +652,7 @@ async def _handle_speaker_assigned(self, payload: Dict[str, Any]) -> None:
649652
async def receive_data(self) -> None:
650653
request = self.host.request
651654
buffer = bytearray()
655+
decoded_audio_bytes = 0
652656
self.host.state.last_audio_received_time = time.time()
653657
self.host.state.last_activity_time = self.host.state.last_audio_received_time
654658
try:
@@ -674,7 +678,7 @@ async def receive_data(self) -> None:
674678
self.host.state.last_usage_record_timestamp = now
675679
self.host.start_live_transcription()
676680
if self.host.is_multi_channel:
677-
await self._handle_multi_channel_audio(data)
681+
decoded_audio_bytes += await self._handle_multi_channel_audio(data)
678682
continue
679683
try:
680684
decoded: bytes = data
@@ -694,6 +698,7 @@ async def receive_data(self) -> None:
694698
self.decode_failure_streak = 0
695699
if not decoded:
696700
continue
701+
decoded_audio_bytes += len(decoded)
697702
self._capture('capture_client_audio', decoded)
698703
if self.host.state.audio_ring_buffer is not None:
699704
self.host.state.audio_ring_buffer.write(decoded, now)
@@ -710,8 +715,35 @@ async def receive_data(self) -> None:
710715
logger.error('Listen receive failure type=%s', type(error).__name__)
711716
self.host.state.close_code = 1011
712717
finally:
718+
if decoded_audio_bytes:
719+
sample_rate = max(1, int(getattr(request, 'sample_rate', 16000)))
720+
emit_product_event(
721+
uid=str(getattr(request, 'uid', '') or ''),
722+
event='Encoded Audio Duration Measured',
723+
properties={
724+
'recording_id': getattr(self.host, 'recording_session_id', None),
725+
'conversation_id': getattr(self.host.state, 'current_conversation_id', None),
726+
'codec': request.codec,
727+
'decoded_audio_bytes': decoded_audio_bytes,
728+
'duration_seconds': decoded_audio_bytes / (sample_rate * 2),
729+
},
730+
)
713731
if self.vad_gate is not None:
732+
vad_metrics = self.vad_gate.get_metrics()
714733
logger.info(json.dumps(self.vad_gate.to_json_log()))
734+
speech_ms = max(0, int(vad_metrics.get('speech_ms_total') or 0))
735+
if speech_ms:
736+
emit_product_event(
737+
uid=str(getattr(request, 'uid', '') or ''),
738+
event='Speech Positive Duration Measured',
739+
properties={
740+
'recording_id': getattr(self.host, 'recording_session_id', None),
741+
'conversation_id': getattr(self.host.state, 'current_conversation_id', None),
742+
'duration_seconds': speech_ms / 1000,
743+
'measurement': 'server_vad',
744+
'vad_mode': vad_metrics.get('mode') or 'unknown',
745+
},
746+
)
715747
if not self.host.use_custom_stt:
716748
await self._flush_stt_buffer(buffer, force=True)
717749
await self._drain_stt_sockets()

backend/routers/listen/runtime.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from utils.onboarding import OnboardingHandler
4848
from utils.observability.transcription import LiveSTTAttempt
4949
from utils.pusher import PusherCircuitBreakerOpen
50+
from utils.product_telemetry import emit_product_event
5051
from utils.stt.streaming import get_stt_service_for_language
5152
from utils.subscription import get_remaining_transcription_seconds, is_trial_paywalled
5253
from utils.transcribe_decisions import (
@@ -183,6 +184,18 @@ def send_event(self, event: MessageEvent) -> None:
183184
self.spawn(self.asend_event(event), name='message_event')
184185

185186
def emit_speaker_suggestion(self, speaker_id: int, person_id: str, person_name: str, segment_id: str) -> None:
187+
emit_product_event(
188+
uid=self.request.uid,
189+
event='Speaker Identity Proposed',
190+
properties={
191+
'recording_id': self.recording_session_id,
192+
'conversation_id': self.state.current_conversation_id,
193+
'speaker_id': speaker_id,
194+
'matched_existing_person': bool(person_id),
195+
'auto_assign_enabled': self.request.speaker_auto_assign_enabled,
196+
'proposal_source': 'live_speaker_identification',
197+
},
198+
)
186199
self.send_event(
187200
SpeakerLabelSuggestionEvent(
188201
speaker_id=speaker_id,
@@ -204,6 +217,12 @@ def start_live_transcription(self) -> None:
204217
self.state.live_transcription_attempt = LiveSTTAttempt(
205218
provider=getattr(self.stt_service, 'value', self.stt_service),
206219
platform=self.client_device_context.platform,
220+
uid=self.request.uid,
221+
recording_id=self.recording_session_id,
222+
conversation_id=self.state.current_conversation_id,
223+
source=self.request.source,
224+
model=self.stt_model,
225+
language=self.stt_language,
207226
)
208227

209228
def capture_client_audio(self, audio: bytes) -> None:

backend/routers/listen/transcripts.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from utils.translation import TranslationService
3939
from utils.translation_cache import ConversationLanguageState, TranscriptSegmentLanguageCache
4040
from utils.translation_coordinator import TranslationCoordinator
41+
from utils.product_telemetry import emit_product_event
4142

4243
logger = logging.getLogger(__name__)
4344

@@ -263,12 +264,18 @@ async def _deliver_segments(self, client_segments: List[Dict[str, Any]]) -> bool
263264
return False
264265

265266
async def process_loop(self) -> None:
267+
diarized_speaker_ids_by_conversation: Dict[str, set[int]] = {}
266268
while self.host.state.active or self.segment_buffer or self.photo_buffer:
267269
if await self.host.wait(0.6) and not (self.segment_buffer or self.photo_buffer):
268270
break
269271
if not self.segment_buffer and not self.photo_buffer:
270272
continue
271273
raw_segments = sort_segments_by_start(list(self.segment_buffer))
274+
conversation_id = self.host.state.current_conversation_id
275+
if conversation_id:
276+
diarized_speaker_ids_by_conversation.setdefault(conversation_id, set()).update(
277+
int(segment['speaker_id']) for segment in raw_segments if isinstance(segment.get('speaker_id'), int)
278+
)
272279
self.segment_buffer.clear()
273280
photos = list(self.photo_buffer)
274281
self.photo_buffer.clear()
@@ -358,6 +365,19 @@ async def process_loop(self) -> None:
358365
logger.warning('Timed out waiting for listen speaker identification to finish')
359366
await self.host.speakers.drain(timeout=10, label='listen_speaker_final')
360367
await self.flush_speaker_assignments(self.host.state.current_conversation_id)
368+
for conversation_id, diarized_speaker_ids in diarized_speaker_ids_by_conversation.items():
369+
if not diarized_speaker_ids:
370+
continue
371+
emit_product_event(
372+
uid=self.host.request.uid,
373+
event='Diarization Completed',
374+
properties={
375+
'recording_id': getattr(self.host, 'recording_session_id', None),
376+
'conversation_id': conversation_id,
377+
'speaker_count': len(diarized_speaker_ids),
378+
'source': 'stt_provider',
379+
},
380+
)
361381

362382
async def _write_fresh(
363383
self,

backend/tests/unit/test_action_item_canonical_contract.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,38 @@ def test_released_macos_request_shapes_remain_compatible_at_route_boundary():
109109
assert 'clear_due_at' not in update.storage_payload()
110110

111111

112+
def test_owner_change_emits_bounded_assignee_correction_after_durable_update(monkeypatch):
113+
existing = {'id': 'task-1', 'description': 'Send budget', 'owner': 'unknown', 'completed': False}
114+
updated = {**existing, 'owner': 'user', 'conversation_id': 'conversation-1'}
115+
emitted = []
116+
monkeypatch.setattr(action_items_router, '_get_valid_action_item', lambda *_: existing)
117+
monkeypatch.setattr(action_items_router.action_items_db, 'update_action_item', lambda *_args, **_kwargs: True)
118+
monkeypatch.setattr(action_items_router.action_items_db, 'get_action_item', lambda *_: updated)
119+
monkeypatch.setattr(action_items_router.task_links, 'validate_task_links', lambda *_args, **_kwargs: None)
120+
monkeypatch.setattr(action_items_router, 'emit_product_event', lambda **event: emitted.append(event))
121+
122+
result = action_items_router.update_action_item(
123+
'task-1',
124+
ActionItemUpdateRequest(owner='user'),
125+
uid='user-1',
126+
)
127+
128+
assert result.owner == 'user'
129+
assert emitted == [
130+
{
131+
'uid': 'user-1',
132+
'event': 'Task Assignee Corrected',
133+
'properties': {
134+
'action_item_id': 'task-1',
135+
'conversation_id': 'conversation-1',
136+
'previous_assignee': 'unknown',
137+
'new_assignee': 'user',
138+
'field_changed': 'owner',
139+
},
140+
}
141+
]
142+
143+
112144
@pytest.mark.parametrize(
113145
'payload',
114146
[

0 commit comments

Comments
 (0)