first commit

This commit is contained in:
2026-08-21 08:37:51 -03:00
commit 881a99b0a8
214 changed files with 61407 additions and 0 deletions

1
tests/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import struct
from app.utils import audio_backlog
FRAME_SAMPLES = 320 # 20 ms @ 16 kHz mono
BYTES_PER_FRAME = FRAME_SAMPLES * 2
def _silent() -> bytes:
return b"\x00" * BYTES_PER_FRAME
def _voiced(amp: int = 8000) -> bytes:
return struct.pack("<%dh" % FRAME_SAMPLES, *([amp] * FRAME_SAMPLES))
def _queue(frames: list[bytes]) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue()
for frame in frames:
q.put_nowait(frame)
return q
def _drain(q: asyncio.Queue) -> list[bytes]:
out = []
while True:
try:
out.append(q.get_nowait())
except asyncio.QueueEmpty:
break
return out
def test_rms_threshold_from_dbfs() -> None:
assert audio_backlog.rms_threshold_from_dbfs(0.0) == 32768
assert audio_backlog.rms_threshold_from_dbfs(-50.0) == 103
assert audio_backlog.rms_threshold_from_dbfs(-200.0) == 0
def test_frames_from_ms_rounds_up_with_minimum() -> None:
assert audio_backlog.frames_from_ms(100, 20) == 5
assert audio_backlog.frames_from_ms(90, 20) == 5 # arredonda pra cima
assert audio_backlog.frames_from_ms(0, 20) == 1 # minimo
assert audio_backlog.frames_from_ms(40, 20) == 2
def test_is_silent_frame() -> None:
thr = audio_backlog.rms_threshold_from_dbfs(-50.0)
assert audio_backlog.is_silent_frame(_silent(), thr) is True
assert audio_backlog.is_silent_frame(_voiced(), thr) is False
def test_shed_below_keep_is_noop() -> None:
q = _queue([_voiced()] * 2)
result = audio_backlog.shed_queue_backlog(q, keep_frames=5, rms_threshold=103, blind=False)
assert result.dropped == 0
assert result.mode == "none"
assert q.qsize() == 2
def test_shed_blind_drops_oldest_and_keeps_recent_in_order() -> None:
frames = [_voiced(1000 + i) for i in range(8)]
q = _queue(frames)
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=True)
assert result.dropped == 6
assert result.dropped_voiced == 6
assert result.mode == "blind"
assert _drain(q) == frames[-2:]
def test_shed_energy_drops_only_silence_preserving_speech_order() -> None:
voiced = [_voiced(2000 + i * 100) for i in range(4)]
# intercala voz e silencio: [V0, S, V1, S, V2, S, V3, S]
frames = [voiced[0], _silent(), voiced[1], _silent(), voiced[2], _silent(), voiced[3], _silent()]
q = _queue(frames)
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=False)
# need = 8 - 2 = 6, mas so ha 4 frames silenciosos -> descarta os 4 silencios,
# preserva os 4 de voz na ordem (nao corta fala).
assert result.mode == "energy"
assert result.dropped == 4
assert result.dropped_silent == 4
assert result.dropped_voiced == 0
assert _drain(q) == voiced
def test_shed_energy_stops_at_need_even_with_more_silence() -> None:
voiced = [_voiced(3000 + i) for i in range(2)]
# 2 de voz + 6 de silencio, keep=4 -> need=4 -> descarta 4 silencios, sobra 4
frames = [voiced[0], _silent(), _silent(), _silent(), voiced[1], _silent(), _silent(), _silent()]
q = _queue(frames)
result = audio_backlog.shed_queue_backlog(q, keep_frames=4, rms_threshold=103, blind=False)
assert result.dropped == 4
assert result.dropped_silent == 4
remaining = _drain(q)
assert q.qsize() == 0
# sobra os 2 de voz (na ordem) + 2 silencios mais recentes
assert remaining[0] == voiced[0]
assert voiced[1] in remaining
assert len(remaining) == 4
def test_shed_disabled_via_empty_queue() -> None:
q = _queue([])
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=True)
assert result.dropped == 0
assert result.mode == "none"

View File

@@ -0,0 +1,178 @@
from __future__ import annotations
import logging
from app.utils import background as background_module
from app.utils.background import (
AudioOutputBacklogConfig,
AudioOutputLatencyTracker,
audio_output_backlog_config_from_env,
)
_LOGGER = logging.getLogger("test.audio_out")
def _capture(monkeypatch) -> list:
events: list = []
monkeypatch.setattr(
background_module,
"log_flow_event",
lambda logger, step, **payload: events.append((step, payload)),
)
return events
def test_output_config_from_env_defaults(monkeypatch) -> None:
for name in (
"AUDIO_OUT_LATENCY_METRICS_ENABLED",
"AUDIO_OUT_LATENCY_ALERT_MS",
"AUDIO_OUT_LATENCY_LOG_INTERVAL_S",
"AUDIO_OUT_PACING_DEBT_THRESHOLD_MS",
"AUDIO_OUT_BACKLOG_SHED_ENABLED",
"AUDIO_OUT_BACKLOG_SHED_THRESHOLD_MS",
"AUDIO_OUT_BACKLOG_SHED_KEEP_MS",
"AUDIO_OUT_BACKLOG_SHED_CHECK_INTERVAL_FRAMES",
"AUDIO_OUT_BACKLOG_SILENCE_DBFS",
):
monkeypatch.delenv(name, raising=False)
config = audio_output_backlog_config_from_env()
assert config.metrics_enabled is True
assert config.shed_enabled is True
assert config.shed_threshold_ms == 400
assert config.shed_keep_ms == 200
assert config.pacing_debt_threshold_ms == 200
assert config.silence_dbfs == -60.0
# frames dependem do frame_ms passado no uso
assert config.shed_threshold_frames(20) == 20
assert config.shed_keep_frames(20) == 10
assert config.silence_rms_threshold == 32
def test_note_pacing_debt_accumulates_and_logs(monkeypatch) -> None:
events = _capture(monkeypatch)
tracker = AudioOutputLatencyTracker(
AudioOutputBacklogConfig(), frame_ms=20, flow_logger=_LOGGER
)
tracker.note_pacing_debt(250, now=1.0)
tracker.note_pacing_debt(300, now=2.0)
tracker.note_pacing_debt(0, now=3.0) # ignora nao-positivo
assert tracker.pacing_debt_events == 2
assert tracker.pacing_debt_dropped_ms == 550
debt_events = [e for e in events if e[0] == "audio_out_pacing_debt"]
assert len(debt_events) == 2
assert debt_events[-1][1]["pacing_debt_dropped_ms"] == 550
assert debt_events[-1][1]["debt_ms"] == 300
def test_record_shed_accumulates_and_logs(monkeypatch) -> None:
events = _capture(monkeypatch)
debug_events = []
tracker = AudioOutputLatencyTracker(
AudioOutputBacklogConfig(),
frame_ms=20,
flow_logger=_LOGGER,
debug_event_publisher=lambda event, payload: debug_events.append(
(event, payload)
),
)
tracker.record_shed(
dropped_frames=3,
dropped_silent=3,
mode="energy",
queue_before_frames=30,
queue_after_frames=27,
)
tracker.record_shed(
dropped_frames=0, # ignora
dropped_silent=0,
mode="energy",
queue_before_frames=10,
queue_after_frames=10,
)
assert tracker.total_dropped_frames == 3
shed_events = [e for e in events if e[0] == "audio_out_latency_shed"]
assert len(shed_events) == 1
payload = shed_events[0][1]
assert payload["mode"] == "energy"
assert payload["dropped_ms"] == 60
assert payload["dropped_silent_frames"] == 3
assert payload["queue_before_ms"] == 600
assert payload["total_dropped_ms"] == 60
assert debug_events[-1][0] == "bridge.audio_out.shed"
assert debug_events[-1][1]["dropped_ms"] == 60
def test_maybe_log_primes_first_then_logs_on_interval(monkeypatch) -> None:
events = _capture(monkeypatch)
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=15.0)
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
# primeira chamada apenas inicializa o relogio (sem log)
tracker.maybe_log(queue_frames=5, now=100.0, reason="tick")
assert [e for e in events if e[0] == "audio_out_latency"] == []
assert tracker.peak_queue_ms == 100 # peak atualiza mesmo sem logar
# antes do intervalo: ainda nao loga
tracker.maybe_log(queue_frames=5, now=110.0, reason="tick")
assert [e for e in events if e[0] == "audio_out_latency"] == []
# passado o intervalo: loga
tracker.maybe_log(queue_frames=5, now=116.0, reason="tick")
out_events = [e for e in events if e[0] == "audio_out_latency"]
assert len(out_events) == 1
assert out_events[0][1]["queue_ms"] == 100
def test_maybe_log_logs_on_alert_threshold(monkeypatch) -> None:
events = _capture(monkeypatch)
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=999.0)
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
tracker.maybe_log(queue_frames=5, now=1.0, reason="tick") # prime
# backlog >= alert (60 frames * 20 = 1200 ms >= 1000) -> loga fora do intervalo
tracker.maybe_log(queue_frames=60, now=1.5, reason="tick")
out_events = [e for e in events if e[0] == "audio_out_latency"]
assert len(out_events) == 1
assert out_events[0][1]["queue_ms"] == 1200
assert out_events[0][1]["peak_queue_ms"] == 1200
def test_metrics_disabled_suppresses_all_logs(monkeypatch) -> None:
events = _capture(monkeypatch)
config = AudioOutputBacklogConfig(metrics_enabled=False)
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
tracker.note_pacing_debt(500, now=1.0)
tracker.record_shed(
dropped_frames=5, dropped_silent=5, mode="energy",
queue_before_frames=40, queue_after_frames=35,
)
tracker.maybe_log(queue_frames=100, now=1.0, reason="tick", force=True)
assert events == []
# contadores ainda acumulam (metrica desligada nao perde estado interno)
assert tracker.pacing_debt_dropped_ms == 500
assert tracker.total_dropped_frames == 5
def test_maybe_log_alert_is_rate_limited_until_it_recovers(monkeypatch) -> None:
events = _capture(monkeypatch)
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=15.0)
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
tracker.maybe_log(queue_frames=5, now=1.0, reason="tick")
tracker.maybe_log(queue_frames=60, now=1.5, reason="alert_start")
tracker.maybe_log(queue_frames=60, now=1.52, reason="alert_still_active")
tracker.maybe_log(queue_frames=5, now=2.0, reason="recovered")
tracker.maybe_log(queue_frames=60, now=2.1, reason="alert_restart")
out_events = [event for event in events if event[0] == "audio_out_latency"]
assert len(out_events) == 2
assert [event[1]["reason"] for event in out_events] == ["alert_start", "alert_restart"]

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
import asyncio
import struct
from fastapi import WebSocketDisconnect
from app.utils.background import BridgeOutputStats, ws_out_loop
class _ClosingWebSocket:
def __init__(self, exc: BaseException) -> None:
self.exc = exc
self.sent_frames = 0
async def send_bytes(self, frame: bytes) -> None:
self.sent_frames += 1
raise self.exc
class _CollectThenDisconnectWebSocket:
def __init__(self, disconnect_after: int) -> None:
self.disconnect_after = disconnect_after
self.sent_frames: list[bytes] = []
async def send_bytes(self, frame: bytes) -> None:
self.sent_frames.append(frame)
if len(self.sent_frames) >= self.disconnect_after:
raise WebSocketDisconnect(code=1000)
class _CaptureThenDisconnectWebSocket:
def __init__(self) -> None:
self.sent_frames: list[bytes] = []
async def send_bytes(self, frame: bytes) -> None:
self.sent_frames.append(frame)
raise WebSocketDisconnect(code=1000)
async def _capture_one_agent_frame(frame: bytes, **ws_out_kwargs) -> _CaptureThenDisconnectWebSocket:
ws = _CaptureThenDisconnectWebSocket()
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
await agent_q.put(frame)
await asyncio.wait_for(
ws_out_loop(
ws,
agent_q,
frame_ms=20,
bytes_per_frame=len(frame),
**ws_out_kwargs,
),
timeout=0.2,
)
return ws
def test_ws_out_loop_exits_when_close_was_already_sent() -> None:
async def _run() -> None:
ws = _ClosingWebSocket(RuntimeError('Cannot call "send" once a close message has been sent.'))
await asyncio.wait_for(
ws_out_loop(
ws,
asyncio.Queue(),
frame_ms=20,
bytes_per_frame=4,
),
timeout=0.2,
)
assert ws.sent_frames == 1
asyncio.run(_run())
def test_ws_out_loop_applies_default_output_gain(monkeypatch) -> None:
monkeypatch.delenv("WS_OUTPUT_GAIN", raising=False)
frame = struct.pack("<hh", 1000, -1000)
ws = asyncio.run(_capture_one_agent_frame(frame))
assert struct.unpack("<hh", ws.sent_frames[0]) == (1000, -1000)
def test_ws_out_loop_reads_output_gain_from_env(monkeypatch) -> None:
monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5")
frame = struct.pack("<hh", 1000, -1000)
ws = asyncio.run(_capture_one_agent_frame(frame))
assert struct.unpack("<hh", ws.sent_frames[0]) == (1500, -1500)
def test_ws_out_loop_prefers_call_config_output_gain(monkeypatch) -> None:
monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5")
frame = struct.pack("<hh", 1000, -1000)
ws = asyncio.run(_capture_one_agent_frame(frame, output_gain=1.0))
assert struct.unpack("<hh", ws.sent_frames[0]) == (1000, -1000)
def test_ws_out_loop_exits_on_websocket_disconnect() -> None:
async def _run() -> None:
ws = _ClosingWebSocket(WebSocketDisconnect(code=1000))
await asyncio.wait_for(
ws_out_loop(
ws,
asyncio.Queue(),
frame_ms=20,
bytes_per_frame=4,
),
timeout=0.2,
)
assert ws.sent_frames == 1
asyncio.run(_run())
def test_ws_out_loop_waits_for_speech_before_signaling_first_agent_audio() -> None:
async def _run() -> None:
silent_frame = struct.pack("<hh", 0, 0)
voiced_frame = struct.pack("<hh", 2000, -2000)
ws = _CollectThenDisconnectWebSocket(disconnect_after=3)
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
await agent_q.put(silent_frame)
await agent_q.put(voiced_frame)
first_audio = asyncio.Event()
stats = BridgeOutputStats()
await asyncio.wait_for(
ws_out_loop(
ws,
agent_q,
frame_ms=20,
bytes_per_frame=len(silent_frame),
first_agent_audio_sent=first_audio,
output_stats=stats,
),
timeout=0.2,
)
assert ws.sent_frames[:2] == [silent_frame, voiced_frame]
assert first_audio.is_set()
assert stats.first_agent_audio_sent is True
assert stats.agent_audio_bursts == 1
asyncio.run(_run())

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import unittest
from app.utils.call_timeline import CallTimeline
class CallTimelineTests(unittest.TestCase):
def test_emit_writes_jsonl_with_relative_time(self) -> None:
logger = logging.getLogger("test.call_timeline")
logger.handlers = []
logger.addHandler(logging.NullHandler())
with tempfile.TemporaryDirectory() as tmpdir:
old_dir = os.environ.get("CALL_TIMELINE_DIR")
old_console = os.environ.get("CALL_TIMELINE_CONSOLE")
old_enabled = os.environ.get("CALL_TIMELINE_ENABLED")
os.environ["CALL_TIMELINE_DIR"] = tmpdir
os.environ["CALL_TIMELINE_CONSOLE"] = "0"
os.environ["CALL_TIMELINE_ENABLED"] = "1"
try:
timeline = CallTimeline(
logger=logger,
component="bridge",
timeline_id="room-dev-123",
protocol="PRT-1",
room="room-dev-123",
session_id="PRT-1",
phone_number="31999999999",
origin_unix_ms=1000,
)
timeline.emit("call_start", ok=True, nested={"a": 1})
timeline.emit("ready_sent")
self.assertTrue(timeline.flush(timeout=5.0))
with timeline.path.open("r", encoding="utf-8") as handle:
lines = [json.loads(line) for line in handle if line.strip()]
finally:
if old_dir is None:
os.environ.pop("CALL_TIMELINE_DIR", None)
else:
os.environ["CALL_TIMELINE_DIR"] = old_dir
if old_console is None:
os.environ.pop("CALL_TIMELINE_CONSOLE", None)
else:
os.environ["CALL_TIMELINE_CONSOLE"] = old_console
if old_enabled is None:
os.environ.pop("CALL_TIMELINE_ENABLED", None)
else:
os.environ["CALL_TIMELINE_ENABLED"] = old_enabled
self.assertEqual(2, len(lines))
self.assertEqual("bridge", lines[0]["component"])
self.assertEqual("call_start", lines[0]["event"])
self.assertEqual("room-dev-123", lines[0]["timeline_id"])
self.assertEqual("PRT-1", lines[0]["protocol"])
self.assertEqual("31999999999", lines[0]["phone_number"])
self.assertTrue(lines[0]["ok"])
self.assertEqual({"a": 1}, lines[0]["nested"])
self.assertGreaterEqual(lines[0]["t_rel_ms"], 0)
self.assertEqual("ready_sent", lines[1]["event"])
def test_background_writer_preserves_order_for_many_events(self) -> None:
logger = logging.getLogger("test.call_timeline.order")
logger.handlers = [logging.NullHandler()]
with tempfile.TemporaryDirectory() as tmpdir:
old_dir = os.environ.get("CALL_TIMELINE_DIR")
old_enabled = os.environ.get("CALL_TIMELINE_ENABLED")
os.environ["CALL_TIMELINE_DIR"] = tmpdir
os.environ["CALL_TIMELINE_ENABLED"] = "1"
try:
timeline = CallTimeline(
logger=logger,
component="bridge",
timeline_id="room-order",
origin_unix_ms=1000,
)
for index in range(200):
timeline.emit("tick", index=index)
self.assertTrue(timeline.flush(timeout=5.0))
with timeline.path.open("r", encoding="utf-8") as handle:
lines = [json.loads(line) for line in handle if line.strip()]
finally:
if old_dir is None:
os.environ.pop("CALL_TIMELINE_DIR", None)
else:
os.environ["CALL_TIMELINE_DIR"] = old_dir
if old_enabled is None:
os.environ.pop("CALL_TIMELINE_ENABLED", None)
else:
os.environ["CALL_TIMELINE_ENABLED"] = old_enabled
self.assertEqual(list(range(200)), [line["index"] for line in lines])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import tempfile
import time
from pathlib import Path
from app.utils.call_timeline import _TimelineWriter
def test_queue_full_is_counted_and_flush_honors_timeout() -> None:
writer = _TimelineWriter(max_queue=1, warning_interval_s=3_600)
writer.start = lambda: True # type: ignore[method-assign]
writer._last_drop_warning_at = time.monotonic()
assert writer.submit(Path("unused"), "first") is True
assert writer.submit(Path("unused"), "second") is False
started = time.monotonic()
assert writer.flush(timeout=0.01) is False
assert time.monotonic() - started < 0.5
assert writer.dropped == 1
def test_write_error_is_counted_without_killing_writer() -> None:
writer = _TimelineWriter(max_queue=10, warning_interval_s=3_600)
writer._last_error_warning_at = time.monotonic()
with tempfile.TemporaryDirectory() as tmpdir:
invalid_path = Path(tmpdir)
assert writer.submit(invalid_path, "line") is True
assert writer.flush(timeout=2.0) is True
assert writer.write_errors == 1
assert writer.shutdown(timeout=2.0) is True
def test_shutdown_drains_pending_lines_and_rejects_new_work() -> None:
writer = _TimelineWriter(max_queue=10)
writer._last_drop_warning_at = time.monotonic()
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "timeline.jsonl"
assert writer.submit(path, "one") is True
assert writer.submit(path, "two") is True
assert writer.shutdown(timeout=2.0) is True
assert path.read_text(encoding="utf-8").splitlines() == ["one", "two"]
assert writer.submit(path, "three") is False
assert writer.dropped == 1

View File

@@ -0,0 +1,292 @@
from __future__ import annotations
import asyncio
import logging
import struct
import wave
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from app.utils import full_call_recording
from app.utils.full_call_recording import (
EntireCallRecorder,
EntireCallUploadItem,
build_entire_call_object_name,
enqueue_entire_call_upload,
)
from app.utils.stt_audio_upload import OCIUploadConfig
def test_entire_call_object_name_uses_date_folder_and_session_filename() -> None:
object_name = build_entire_call_object_name(
session_id="sessao muito longa/" * 30,
now=datetime(2026, 7, 8, 10, 30, 0),
)
parts = object_name.split("/")
assert parts[0] == "2026-07-08"
assert parts[1] == "entire_call"
assert object_name.endswith(".wav")
assert len(parts) == 3
assert len(parts[2]) <= 68
assert " " not in object_name
def test_entire_call_recorder_writes_stereo_wav_without_upload(tmp_path: Path) -> None:
async def _run() -> Path:
recorder = EntireCallRecorder(
session_id="session-1",
sample_rate=1000,
channels=1,
sample_width=2,
frame_ms=20,
bytes_per_frame=4,
tmp_dir=tmp_path,
logger_override=logging.getLogger("test.full_call_recording.wav"),
object_name="2026-07-08/entire_call/session-1.wav",
)
recorder.start()
recorder.record_client_frame(struct.pack("<hh", 1000, -1000))
recorder.record_output_frame(struct.pack("<hh", 2000, 500))
result = await recorder.finalize(enqueue_upload=False)
assert result is not None
assert result.frames == 1
assert result.duration_ms == 20
assert result.upload_enqueued is False
return result.path
wav_path = asyncio.run(_run())
with wave.open(str(wav_path), "rb") as handle:
assert handle.getframerate() == 1000
assert handle.getnchannels() == 2
assert handle.getsampwidth() == 2
# Stereo samples are interleaved as client-left, agent-right.
assert struct.unpack("<hhhh", handle.readframes(2)) == (
1000,
2000,
-1000,
500,
)
def test_entire_call_recorder_keeps_each_side_in_its_own_channel(tmp_path: Path) -> None:
async def _run() -> Path:
recorder = EntireCallRecorder(
session_id="session-separated",
sample_rate=1000,
channels=1,
sample_width=2,
frame_ms=20,
bytes_per_frame=4,
tmp_dir=tmp_path,
logger_override=logging.getLogger("test.full_call_recording.separated"),
)
recorder.start()
recorder.record_output_frame(struct.pack("<hh", 300, 400))
recorder.record_client_frame(struct.pack("<hh", 100, 200))
result = await recorder.finalize(enqueue_upload=False)
assert result is not None
return result.path
with wave.open(str(asyncio.run(_run())), "rb") as handle:
assert handle.getnchannels() == 2
samples = struct.unpack("<hhhhhhhh", handle.readframes(4))
assert samples == (
0,
300,
0,
400,
100,
0,
200,
0,
)
def test_entire_call_recorder_accepts_metadata_with_reserved_names(tmp_path: Path) -> None:
async def _run() -> None:
recorder = EntireCallRecorder(
session_id="session-1",
sample_rate=1000,
channels=1,
sample_width=2,
frame_ms=20,
bytes_per_frame=4,
tmp_dir=tmp_path,
logger_override=logging.getLogger("test.full_call_recording.metadata"),
metadata={"session_id": "session-1", "room": "room-1"},
object_name="2026-07-08/entire_call/session-1.wav",
)
recorder.start()
recorder.record_output_frame(b"\x00\x00\x00\x00")
result = await recorder.finalize(enqueue_upload=False)
assert result is not None
asyncio.run(_run())
def test_entire_call_recorder_queue_full_drops_without_raising(tmp_path: Path) -> None:
recorder = EntireCallRecorder(
session_id="session-1",
sample_rate=1000,
channels=1,
sample_width=2,
frame_ms=20,
bytes_per_frame=4,
tmp_dir=tmp_path,
logger_override=logging.getLogger("test.full_call_recording.drop"),
queue_size=1,
)
recorder._started = True
recorder._queue.put_nowait(object())
recorder.record_client_frame(b"\x00\x00\x00\x00")
assert recorder.dropped_queue_frames == 1
def test_upload_item_streams_file_to_oci(monkeypatch, tmp_path: Path) -> None:
calls: dict[str, object] = {}
wav_path = tmp_path / "call.wav"
wav_path.write_bytes(b"fake-wav")
config = OCIUploadConfig(
auth_mode="oke_workload_identity",
region="sa-saopaulo-1",
bucket="tia-audio",
namespace="namespace",
)
class FakeClient:
def put_object(self, **kwargs):
calls["namespace_name"] = kwargs["namespace_name"]
calls["bucket_name"] = kwargs["bucket_name"]
calls["object_name"] = kwargs["object_name"]
calls["body"] = kwargs["put_object_body"].read()
return SimpleNamespace(status=200)
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: config)
monkeypatch.setattr(full_call_recording, "_oci_client", lambda _config: FakeClient())
item = EntireCallUploadItem(
object_name="2026-07-08/entire_call/session-1.wav",
path=wav_path,
session_id="session-1",
bytes=8,
duration_ms=20,
dropped_queue_frames=0,
dropped_client_buffer_frames=0,
metadata={},
)
asyncio.run(full_call_recording._upload_item(item))
assert calls == {
"namespace_name": "namespace",
"bucket_name": "tia-audio",
"object_name": "2026-07-08/entire_call/session-1.wav",
"body": b"fake-wav",
}
def test_upload_item_retries_with_a_fresh_client_after_transient_failure(
monkeypatch,
tmp_path: Path,
) -> None:
wav_path = tmp_path / "call.wav"
wav_path.write_bytes(b"fake-wav")
config = OCIUploadConfig(
auth_mode="oke_workload_identity",
region="sa-saopaulo-1",
bucket="tia-audio",
namespace="namespace",
)
attempts = 0
resets = 0
class FakeClient:
def put_object(self, **_kwargs):
nonlocal attempts
attempts += 1
if attempts == 1:
raise RuntimeError("transient connection failure")
return SimpleNamespace(status=200)
def fake_reset() -> None:
nonlocal resets
resets += 1
monkeypatch.setenv("ENTIRE_CALL_RECORDING_UPLOAD_RETRY_BASE_DELAY_S", "0")
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: config)
monkeypatch.setattr(full_call_recording, "_oci_client", lambda _config: FakeClient())
monkeypatch.setattr(full_call_recording, "_reset_oci_client", fake_reset)
item = EntireCallUploadItem(
object_name="2026-07-08/entire_call/session-retry.wav",
path=wav_path,
session_id="session-retry",
bytes=8,
duration_ms=20,
dropped_queue_frames=0,
dropped_client_buffer_frames=0,
metadata={},
)
asyncio.run(full_call_recording._upload_item(item))
assert attempts == 2
assert resets == 1
def test_upload_worker_keeps_file_after_all_attempts_fail(monkeypatch, tmp_path: Path) -> None:
async def _run() -> None:
wav_path = tmp_path / "failed.wav"
wav_path.write_bytes(b"recoverable-wav")
item = EntireCallUploadItem(
object_name="2026-07-08/entire_call/session-failed.wav",
path=wav_path,
session_id="session-failed",
bytes=15,
duration_ms=20,
dropped_queue_frames=0,
dropped_client_buffer_frames=0,
metadata={},
)
async def fail_upload(_item) -> None:
raise RuntimeError("OCI unavailable")
monkeypatch.setattr(full_call_recording, "_upload_item", fail_upload)
worker = full_call_recording._EntireCallUploadWorker(asyncio.get_running_loop())
assert worker.enqueue(item)
await asyncio.wait_for(worker._queue.join(), timeout=1)
assert wav_path.read_bytes() == b"recoverable-wav"
worker._task.cancel()
asyncio.run(_run())
def test_enqueue_entire_call_upload_is_noop_and_keeps_file_without_oci_config(
monkeypatch,
tmp_path: Path,
) -> None:
wav_path = tmp_path / "call.wav"
wav_path.write_bytes(b"fake-wav")
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: None)
assert (
enqueue_entire_call_upload(
path=wav_path,
object_name="2026-07-08/entire_call/session-1.wav",
session_id="session-1",
bytes=8,
duration_ms=20,
logger_override=logging.getLogger("test.full_call_recording.noop"),
)
is None
)
assert wav_path.read_bytes() == b"fake-wav"

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
import logging
import os
import io
import tempfile
import time
from pathlib import Path
import app.utils.logging as app_logging
from app.utils.logging import _LogWriter, get_call_logger
class _FailingHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
raise OSError("disk unavailable")
def test_session_console_formatter_appends_context_without_duplicating() -> None:
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(app_logging._SessionConsoleFormatter("%(message)s"))
logger = logging.getLogger(f"test.session.console.{time.time_ns()}")
logger.handlers = [handler]
logger.propagate = False
logger.setLevel(logging.INFO)
try:
app_logging.set_log_session_id("session-123")
logger.info("FLOW | step=test")
logger.info("FLOW | step=test | session_id=session-123")
finally:
app_logging.set_log_session_id("")
assert stream.getvalue().splitlines() == [
"FLOW | step=test | session_id=session-123",
"FLOW | step=test | session_id=session-123",
]
def test_session_console_formatter_keeps_structured_json_valid() -> None:
formatter = app_logging._SessionConsoleFormatter("%(message)s")
try:
app_logging.set_log_session_id("session-456")
record = logging.LogRecord(
"test",
logging.INFO,
"",
0,
'{"tipo_evento":"envio msg","session_id":"session-456"}',
(),
None,
)
assert formatter.format(record) == (
'{"tipo_evento":"envio msg","session_id":"session-456"}'
)
finally:
app_logging.set_log_session_id("")
def test_call_file_handler_is_async_and_writes_to_disk() -> None:
with tempfile.TemporaryDirectory() as tmp:
saved = {key: os.environ.get(key) for key in ("LOG_DIR", "LOG_TO_FILE")}
os.environ["LOG_DIR"] = tmp
os.environ["LOG_TO_FILE"] = "1"
try:
logger = get_call_logger(
phone_number="5500000000123",
session_id=f"async{time.time_ns()}",
)
async_handlers = [
handler
for handler in logger.handlers
if isinstance(handler, app_logging._AsyncFileHandler)
]
assert len(async_handlers) == 1
logger.info("LINHA_DE_TESTE_ASYNC_123")
assert app_logging._LOG_WRITER.flush(timeout=5.0)
files = list(Path(tmp).glob("*.txt"))
assert len(files) == 1
assert "LINHA_DE_TESTE_ASYNC_123" in files[0].read_text(encoding="utf-8")
finally:
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def test_queue_full_is_counted_and_flush_honors_timeout() -> None:
writer = _LogWriter(max_queue=1, warning_interval_s=3_600)
writer.start = lambda: True # type: ignore[method-assign]
writer._last_drop_warning_at = time.monotonic()
handler = logging.NullHandler()
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
assert writer.submit(handler, record) is True
assert writer.submit(handler, record) is False
started = time.monotonic()
assert writer.flush(timeout=0.01) is False
assert time.monotonic() - started < 0.5
assert writer.dropped == 1
def test_handler_error_is_counted_and_shutdown_stays_healthy() -> None:
writer = _LogWriter(max_queue=10, warning_interval_s=3_600)
writer._last_error_warning_at = time.monotonic()
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
assert writer.submit(_FailingHandler(), record) is True
assert writer.flush(timeout=2.0) is True
assert writer.write_errors == 1
assert writer.shutdown(timeout=2.0) is True

View File

@@ -0,0 +1,460 @@
from __future__ import annotations
import json
import logging
import uuid
from app.utils import logging as logging_utils
from app.utils import structured_otlp
from app.utils import structured_pubsub
class _FakeFuture:
def add_done_callback(self, callback):
callback(self)
def result(self):
return "message-id"
class _FakePublisher:
def __init__(self) -> None:
self.calls = []
def publish(self, *args, **kwargs):
self.calls.append((args, kwargs))
return _FakeFuture()
def topic_path(self, project_id: str, topic: str) -> str:
return f"projects/{project_id}/topics/{topic}"
def test_log_structured_event_publishes_to_pubsub_when_configured(monkeypatch) -> None:
published_events = []
monkeypatch.setenv("STRUCTURED_EVENT_LOG_ENABLED", "1")
monkeypatch.setattr(logging_utils, "publish_structured_event", published_events.append)
logger = logging.getLogger("test.logging_pubsub")
logger.handlers = [logging.NullHandler()]
logger.setLevel(logging.INFO)
event = logging_utils.log_structured_event(
logger,
logging_utils.StructuredLogContext(
callid="call-1",
session_id="session-1",
num_telefone="5511999999999",
cod_ani="1234",
nome_agente="conta",
),
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
message_id="message-1",
inicio_ns=1_700_000_000_000_000_000,
)
assert event is not None
assert published_events == [event]
def test_log_structured_event_publishes_to_pubsub_and_otlp_when_trace_id_is_valid(monkeypatch) -> None:
published_events = []
published_spans = []
monkeypatch.setenv("STRUCTURED_EVENT_LOG_ENABLED", "1")
monkeypatch.setattr(logging_utils, "publish_structured_event", published_events.append)
monkeypatch.setattr(logging_utils, "publish_structured_span", published_spans.append)
logger = logging.getLogger("test.logging_pubsub_otlp")
logger.handlers = [logging.NullHandler()]
logger.setLevel(logging.INFO)
event = logging_utils.log_structured_event(
logger,
logging_utils.StructuredLogContext(
callid="call-1",
session_id="550e8400-e29b-41d4-a716-446655440101",
num_telefone="5511999999999",
cod_ani="1234",
nome_agente="conta",
),
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
message_id="message-1",
inicio_ns=1_700_000_000_000_000_000,
)
assert event is not None
assert published_events == [event]
assert published_spans == [event]
def test_publish_structured_event_sends_json_to_configured_topic(monkeypatch) -> None:
fake_publisher = _FakePublisher()
monkeypatch.setenv("GCP_PROJECT_ID", "project-1")
monkeypatch.setenv("AGENT_PUBSUB_TOPIC", "agent-logs")
monkeypatch.setattr(structured_pubsub, "_PUBLISHER", fake_publisher)
monkeypatch.setattr(structured_pubsub, "_TOPIC_PATH", "projects/project-1/topics/agent-logs")
event = {
"tipo_evento": "recebimento msg",
"dat_hora_inicio": "2026-05-11T22:07:29.965Z",
"dat_hora_fim": "2026-05-11T22:07:30.965Z",
"callid": "call-1",
"session_id": "session-1",
"nome_agente": "conta",
"cod_ani": "1234",
"message_id": "message-1",
"original_message_id": "original-message-1",
"http_cod_status": 200,
"http_cod_desc": "OK",
}
structured_pubsub.publish_structured_event(event)
assert len(fake_publisher.calls) == 1
args, kwargs = fake_publisher.calls[0]
assert args[0] == "projects/project-1/topics/agent-logs"
payload = json.loads(args[1].decode("utf-8"))
assert payload == {
"tipo_evento": "recebimento msg",
"dat_hora_inicio": "11/05/2026 19:07:29,965000000",
"dat_hora_termino": "11/05/2026 19:07:30,965000000",
"callid": "call-1",
"sessionId": "session-1",
"nome_agente": "conta",
"cod_ani": "1234",
"message_id": "message-1",
"http_cod_status": 200,
"http_cod_desc": "OK",
}
assert "session_id" not in payload
assert "dat_hora_fim" not in payload
assert "original_message_id" not in payload
assert kwargs == {
"tipo_evento": "recebimento msg",
"dat_hora_inicio": "11/05/2026 19:07:29,965000000",
"dat_hora_termino": "11/05/2026 19:07:30,965000000",
"sessionId": "session-1",
"callid": "call-1",
"nome_agente": "conta",
"cod_ani": "1234",
"http_cod_status": "200",
"http_cod_desc": "OK",
}
def test_log_structured_event_skips_pubsub_without_required_env(monkeypatch) -> None:
fake_publisher = _FakePublisher()
monkeypatch.delenv("GCP_PROJECT_ID", raising=False)
monkeypatch.delenv("AGENT_PUBSUB_TOPIC", raising=False)
monkeypatch.setattr(structured_pubsub, "_PUBLISHER", fake_publisher)
monkeypatch.setattr(structured_pubsub, "_TOPIC_PATH", "projects/project-1/topics/agent-logs")
structured_pubsub.publish_structured_event({"tipo_evento": "envio msg"})
assert fake_publisher.calls == []
def test_build_structured_event_includes_session_id_and_lowercase_nome_agente() -> None:
event = logging_utils.build_structured_event(
logging_utils.StructuredLogContext(
callid="call-1",
session_id="session-1",
num_telefone="5511999999999",
cod_ani="1234",
nome_agente="conta",
),
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
message_id="message-1",
inicio_ns=1_700_000_000_000_000_000,
)
assert event["session_id"] == "session-1"
assert event["nome_agente"] == "conta"
assert event["dat_hora_fim"] == ""
assert event["latencia_total_STT_TTS"] == ""
assert event["latencia_TFFB_STT_TTS"] == ""
assert event["duracao_audio"] == ""
assert event["tts_max_gap_ms"] == ""
assert event["tts_max_underrun_0ms"] == ""
assert event["tts_underflow_count"] == ""
assert event["tts_avg_underflow_ms"] == ""
assert "tts_clear_rtt_ms" not in event
assert "tts_connection_queue_wait_ms" not in event
assert "tts_clear_stale_messages" not in event
assert "tts_clear_stale_audio_bytes" not in event
assert event["interrupcao"] == ""
assert event["erro_msg"] == ""
assert event["erro_detalhe"] == ""
assert event["http_cod_status"] == ""
assert event["http_cod_desc"] == ""
assert event["finalizacao"] == ""
assert all(value is not None for value in event.values())
assert "Nome_agente" not in event
def test_build_structured_event_includes_audio_duration_and_interruption_flag() -> None:
event = logging_utils.build_structured_event(
logging_utils.StructuredLogContext(
callid="call-1",
session_id="session-1",
num_telefone="5511999999999",
cod_ani="1234",
nome_agente="conta",
),
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
message_id="message-1",
inicio_ns=1_700_000_000_000_000_000,
duracao_audio_ms=2400,
tts_max_gap_ms=840,
tts_max_underrun_0ms=125,
interrupcao=True,
)
assert event["duracao_audio"] == 2400
assert event["tts_max_gap_ms"] == 840
assert event["tts_max_underrun_0ms"] == 125
assert event["interrupcao"] == 1
def test_build_structured_event_uses_uuid_when_message_id_is_missing(monkeypatch) -> None:
generated = uuid.UUID("12345678-1234-4234-9234-123456789abc")
monkeypatch.setattr(logging_utils.uuid, "uuid4", lambda: generated)
event = logging_utils.build_structured_event(
logging_utils.StructuredLogContext(
callid="call-1",
session_id="session-1",
num_telefone="5511999999999",
cod_ani="1234",
nome_agente="conta",
),
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
inicio_ns=1_700_000_000_000_000_000,
)
assert event["message_id"] == "12345678-1234-4234-9234-123456789abc"
def test_error_message_from_resource_uses_event_specific_contract() -> None:
assert (
logging_utils.error_message_from_resource(
resource="agent_runtime",
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
)
== "Falha TIA"
)
assert (
logging_utils.error_message_from_resource(
resource="stt",
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
)
== "Falha STT"
)
assert (
logging_utils.error_message_from_resource(
resource="bridge",
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
)
== "Falha comunicacao"
)
assert (
logging_utils.error_message_from_resource(
status="stop_silencio_longo",
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
)
== "Silencio Longo"
)
assert (
logging_utils.error_message_from_resource(
resource="agent_backend",
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
)
== "Falha comunicacao"
)
assert (
logging_utils.error_message_from_resource(
resource="tts",
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
)
== "Falha TTS"
)
assert (
logging_utils.error_message_from_resource(
status="transferred",
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
)
== "Transferido"
)
def test_format_event_timestamp_uses_iso_utc_milliseconds() -> None:
assert logging_utils.format_event_timestamp(1_700_000_001_234_567_890) == (
"2023-11-14T22:13:21.234Z"
)
def test_structured_context_from_start_data_keeps_session_id_empty_when_not_provided() -> None:
context = logging_utils.structured_context_from_start_data(
{
"callIdGed": "ged-1",
"gsm": "5511999999999",
"ani": "1234",
"agent": "conta",
}
)
assert context.callid == "ged-1"
assert context.session_id == ""
def test_topic_path_accepts_short_topic_or_full_resource_name() -> None:
fake_publisher = _FakePublisher()
assert structured_pubsub._topic_path(fake_publisher, "project-1", "agent-logs") == (
"projects/project-1/topics/agent-logs"
)
assert structured_pubsub._topic_path(
fake_publisher,
"project-1",
"projects/another-project/topics/agent-logs",
) == "projects/another-project/topics/agent-logs"
def test_trace_id_from_session_id_sanitizes_uuid_and_rejects_invalid_values() -> None:
assert structured_otlp.trace_id_from_session_id(
"550e8400-e29b-41d4-a716-446655440101"
) == "550e8400e29b41d4a716446655440101"
assert structured_otlp.trace_id_from_session_id("sess-001") == ""
assert structured_otlp.trace_id_from_session_id("00000000-0000-0000-0000-000000000000") == ""
def _install_memory_tracer(monkeypatch):
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
endpoint = "http://otel.example/v1/traces"
exporter = InMemorySpanExporter()
provider = TracerProvider(id_generator=structured_otlp._ID_GENERATOR)
provider.add_span_processor(SimpleSpanProcessor(exporter))
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", endpoint)
monkeypatch.setattr(structured_otlp, "_TRACER_PROVIDER", provider)
monkeypatch.setattr(structured_otlp, "_TRACER", provider.get_tracer("test.structured_otlp"))
monkeypatch.setattr(structured_otlp, "_TRACER_ENDPOINT", endpoint)
return exporter
def test_publish_structured_span_exports_otlp_span_with_timestamps_and_attributes(monkeypatch) -> None:
exporter = _install_memory_tracer(monkeypatch)
start_ns = 1_700_000_000_000_000_000
end_ns = 1_700_000_001_234_000_000
event = {
"tipo_evento": "envio msg",
"message_id": "message-1",
"dat_hora_inicio": logging_utils.format_event_timestamp(start_ns),
"dat_hora_fim": logging_utils.format_event_timestamp(end_ns),
"callid": "call-1",
"session_id": "550e8400-e29b-41d4-a716-446655440101",
"num_telefone": "5511999999999",
"cod_ani": "1234",
"latencia_total_STT_TTS": 1234,
"latencia_TFFB_STT_TTS": 123,
"erro_msg": "Falha TTS",
"erro_detalhe": "",
"http_cod_status": "",
"http_cod_desc": "",
"finalizacao": "",
"nome_agente": "conta",
"original_message_id": "original-message-1",
}
structured_otlp.publish_structured_span(event)
spans = exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "structured_log.envio msg"
assert span.context.trace_id == int("550e8400e29b41d4a716446655440101", 16)
assert span.context.span_id != 0
assert span.parent is None
assert span.start_time == start_ns
assert span.end_time == end_ns
assert span.attributes["tipo_evento"] == "envio msg"
assert span.attributes["message_id"] == "message-1"
assert span.attributes["latencia_total_STT_TTS"] == 1234
assert span.attributes["erro_msg"] == "Falha TTS"
assert span.attributes["erro_detalhe"] == ""
assert span.attributes["http_cod_status"] == ""
assert span.attributes["http_cod_desc"] == ""
assert span.attributes["finalizacao"] == ""
assert "original_message_id" not in span.attributes
assert span.status.status_code.name == "ERROR"
def test_publish_structured_span_keeps_same_trace_id_and_lets_sdk_generate_span_id(monkeypatch) -> None:
exporter = _install_memory_tracer(monkeypatch)
trace_id = int("550e8400e29b41d4a716446655440101", 16)
base_event = {
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
"session_id": "550e8400-e29b-41d4-a716-446655440101",
}
structured_otlp.publish_structured_span({**base_event, "tipo_evento": "recebimento msg"})
structured_otlp.publish_structured_span({**base_event, "tipo_evento": "envio msg"})
spans = exporter.get_finished_spans()
assert len(spans) == 2
assert {span.context.trace_id for span in spans} == {trace_id}
assert spans[0].context.span_id != spans[1].context.span_id
assert spans[0].parent is None
assert spans[1].parent is None
def test_publish_structured_span_omits_empty_tffb_attribute(monkeypatch) -> None:
exporter = _install_memory_tracer(monkeypatch)
structured_otlp.publish_structured_span(
{
"tipo_evento": "envio msg",
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
"session_id": "550e8400-e29b-41d4-a716-446655440101",
"latencia_TFFB_STT_TTS": "",
}
)
spans = exporter.get_finished_spans()
assert len(spans) == 1
assert "latencia_TFFB_STT_TTS" not in spans[0].attributes
def test_publish_structured_span_skips_invalid_session_id(monkeypatch) -> None:
exporter = _install_memory_tracer(monkeypatch)
structured_otlp.publish_structured_span(
{
"tipo_evento": "recebimento msg",
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
"session_id": "sess-001",
}
)
assert exporter.get_finished_spans() == ()
def test_publish_structured_span_skips_without_otlp_endpoint(monkeypatch) -> None:
monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
monkeypatch.setattr(structured_otlp, "_TRACER", None)
monkeypatch.setattr(structured_otlp, "_TRACER_ENDPOINT", "")
structured_otlp.publish_structured_span(
{
"tipo_evento": "recebimento msg",
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
"session_id": "550e8400-e29b-41d4-a716-446655440101",
}
)

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import os
from opentelemetry.sdk.trace.export import SpanExportResult
import app.utils.structured_otlp as structured_otlp
class _RecordingExporter:
def __init__(self, *args: object, **kwargs: object) -> None:
self.exported: list[int] = []
self.shutdown_calls = 0
def export(self, spans: object) -> SpanExportResult:
self.exported.append(len(list(spans)))
return SpanExportResult.SUCCESS
def shutdown(self) -> None:
self.shutdown_calls += 1
def force_flush(self, timeout_millis: int = 30_000) -> bool:
return True
def _reset_provider_without_shutdown() -> None:
with structured_otlp._TRACER_LOCK:
structured_otlp._TRACER = None
structured_otlp._TRACER_PROVIDER = None
structured_otlp._TRACER_ENDPOINT = ""
def test_span_export_is_batched_not_synchronous(monkeypatch) -> None:
old_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = "http://localhost:4318/v1/traces"
exporter = _RecordingExporter()
monkeypatch.setattr(
structured_otlp,
"OTLPSpanExporter",
lambda *args, **kwargs: exporter,
)
_reset_provider_without_shutdown()
try:
structured_otlp.publish_structured_span(
{
"session_id": "0123456789abcdef0123456789abcdef",
"tipo_evento": "audio_in",
"dat_hora_inicio": "2026-07-24T12:00:00.000000Z",
"dat_hora_fim": "2026-07-24T12:00:00.010000Z",
}
)
assert exporter.exported == []
assert structured_otlp.force_flush_structured_otlp(timeout_millis=5_000)
assert sum(exporter.exported) >= 1
finally:
structured_otlp.shutdown_structured_otlp()
if old_endpoint is None:
os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None)
else:
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = old_endpoint
def test_shutdown_flushes_provider_once(monkeypatch) -> None:
old_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = "http://localhost:4318/v1/traces"
exporter = _RecordingExporter()
monkeypatch.setattr(
structured_otlp,
"OTLPSpanExporter",
lambda *args, **kwargs: exporter,
)
_reset_provider_without_shutdown()
try:
assert structured_otlp._tracer() is not None
structured_otlp.shutdown_structured_otlp()
structured_otlp.shutdown_structured_otlp()
assert exporter.shutdown_calls == 1
assert structured_otlp._TRACER_PROVIDER is None
finally:
structured_otlp.shutdown_structured_otlp()
if old_endpoint is None:
os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None)
else:
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = old_endpoint

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
import sys
import types
from datetime import datetime
from app.utils import stt_audio_upload
from app.utils.stt_audio_upload import (
build_stt_vad_object_name,
enqueue_stt_vad_audio_upload,
)
_BUCKET_ENV_NAMES = (
"OCI_AUTH_MODE",
"BUCKET_USER_ID",
"BUCKET_PRIVATE_KEY",
"BUCKET_FINGERPRINT",
"BUCKET_TENANCY_ID",
"BUCKET_REGION",
"BUCKET_NAME",
"BUCKET_NAMESPACE",
)
def _clear_bucket_env(monkeypatch) -> None:
for name in _BUCKET_ENV_NAMES:
monkeypatch.delenv(name, raising=False)
def _install_fake_oci_modules(monkeypatch) -> dict[str, object]:
calls: dict[str, object] = {}
class FakeObjectStorageClient:
def __init__(self, config, **kwargs) -> None:
calls["client_config"] = config
calls["client_kwargs"] = kwargs
def fake_oke_signer() -> object:
signer = object()
calls["signer"] = signer
return signer
fake_oci = types.ModuleType("oci")
fake_auth = types.ModuleType("oci.auth")
fake_signers = types.ModuleType("oci.auth.signers")
fake_object_storage = types.ModuleType("oci.object_storage")
fake_object_storage.ObjectStorageClient = FakeObjectStorageClient
fake_signers.get_oke_workload_identity_resource_principal_signer = fake_oke_signer
fake_auth.signers = fake_signers
fake_oci.auth = fake_auth
fake_oci.object_storage = fake_object_storage
monkeypatch.setitem(sys.modules, "oci", fake_oci)
monkeypatch.setitem(sys.modules, "oci.auth", fake_auth)
monkeypatch.setitem(sys.modules, "oci.auth.signers", fake_signers)
monkeypatch.setitem(sys.modules, "oci.object_storage", fake_object_storage)
return calls
def _reset_oci_client_cache(monkeypatch) -> None:
monkeypatch.setattr(stt_audio_upload, "_OCI_CLIENT", None)
monkeypatch.setattr(stt_audio_upload, "_OCI_CLIENT_CONFIG", None)
def test_stt_vad_object_name_uses_requested_layout_and_stays_short() -> None:
object_name = build_stt_vad_object_name(
session_id="sessao muito longa/" * 30,
message_id="message-id-muito-longo/" * 30,
req_id="req-id-muito-longo/" * 30,
now=datetime(2026, 6, 24, 13, 52, 21),
)
parts = object_name.split("/")
assert parts[0] == "2026-06-24"
assert object_name.endswith(".wav")
assert len(parts) == 3
assert len(object_name.encode("utf-8")) <= 512
assert all(len(part) <= 68 for part in parts[1:])
assert " " not in object_name
def test_stt_vad_object_name_uses_message_id_as_filename() -> None:
object_name = build_stt_vad_object_name(
session_id="417a5a59-961a-4ba6-9c68-71e92a3da46d",
message_id="0bc6875e-ebd1-4dca-a43d-2f7de404895b",
req_id="c182775c8c114e3f833bfb7675e0be70",
now=datetime(2026, 6, 29, 10, 30, 0),
)
assert (
object_name
== "2026-06-29/417a5a59-961a-4ba6-9c68-71e92a3da46d/"
"0bc6875e-ebd1-4dca-a43d-2f7de404895b.wav"
)
assert "c182775c8c114e3f833bfb7675e0be70" not in object_name
def test_oci_upload_config_reads_bucket_env_and_private_key(monkeypatch) -> None:
_clear_bucket_env(monkeypatch)
monkeypatch.setenv("OCI_AUTH_MODE", "local")
monkeypatch.setenv("BUCKET_USER_ID", "user-ocid")
monkeypatch.setenv("BUCKET_PRIVATE_KEY", "-----BEGIN KEY-----\\nabc\\n-----END KEY-----")
monkeypatch.setenv("BUCKET_FINGERPRINT", "fingerprint")
monkeypatch.setenv("BUCKET_TENANCY_ID", "tenancy-ocid")
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
config = stt_audio_upload._oci_upload_config_from_env()
assert config is not None
assert config.auth_mode == "local"
assert config.bucket == "tia-audio"
assert config.namespace == "namespace"
assert config.key_content == "-----BEGIN KEY-----\nabc\n-----END KEY-----"
def test_oci_upload_config_uses_oke_when_auth_mode_is_absent(monkeypatch) -> None:
_clear_bucket_env(monkeypatch)
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
config = stt_audio_upload._oci_upload_config_from_env()
assert config is not None
assert config.auth_mode == "oke_workload_identity"
assert config.bucket == "tia-audio"
assert config.namespace == "namespace"
assert config.user == ""
assert config.key_content == ""
def test_oci_upload_config_uses_oke_when_auth_mode_is_not_local(monkeypatch) -> None:
_clear_bucket_env(monkeypatch)
monkeypatch.setenv("OCI_AUTH_MODE", "prd")
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
config = stt_audio_upload._oci_upload_config_from_env()
assert config is not None
assert config.auth_mode == "oke_workload_identity"
def test_oci_upload_config_is_missing_when_local_required_env_is_absent(monkeypatch) -> None:
_clear_bucket_env(monkeypatch)
monkeypatch.setenv("OCI_AUTH_MODE", "local")
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
assert stt_audio_upload._oci_upload_config_from_env() is None
def test_oci_upload_config_is_missing_when_oke_required_env_is_absent(monkeypatch) -> None:
_clear_bucket_env(monkeypatch)
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
assert stt_audio_upload._oci_upload_config_from_env() is None
def test_oci_client_uses_api_key_config_for_local_mode(monkeypatch) -> None:
_reset_oci_client_cache(monkeypatch)
calls = _install_fake_oci_modules(monkeypatch)
config = stt_audio_upload.OCIUploadConfig(
auth_mode="local",
region="sa-saopaulo-1",
bucket="tia-audio",
namespace="namespace",
user="user-ocid",
key_content="private-key",
fingerprint="fingerprint",
tenancy="tenancy-ocid",
)
stt_audio_upload._oci_client(config)
assert calls["client_config"] == {
"user": "user-ocid",
"key_content": "private-key",
"fingerprint": "fingerprint",
"tenancy": "tenancy-ocid",
"region": "sa-saopaulo-1",
}
assert calls["client_kwargs"] == {}
assert "signer" not in calls
def test_oci_client_uses_oke_signer_for_workload_identity(monkeypatch) -> None:
_reset_oci_client_cache(monkeypatch)
calls = _install_fake_oci_modules(monkeypatch)
config = stt_audio_upload.OCIUploadConfig(
auth_mode="oke_workload_identity",
region="sa-saopaulo-1",
bucket="tia-audio",
namespace="namespace",
)
stt_audio_upload._oci_client(config)
assert calls["client_config"] == {"region": "sa-saopaulo-1"}
assert calls["client_kwargs"] == {"signer": calls["signer"]}
def test_enqueue_stt_vad_audio_upload_is_noop_without_oci_config(monkeypatch) -> None:
monkeypatch.setattr(stt_audio_upload, "_oci_upload_config_from_env", lambda: None)
assert (
enqueue_stt_vad_audio_upload(
wav_bytes=b"fake-wav",
req_id="req-1",
message_id="message-1",
structured_log_context={"session_id": "session-1"},
)
is None
)

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import uuid
from unittest import mock
from app.utils.turn_ids import (
clear_started_turn_message_id,
next_turn_message_id,
peek_started_turn_message_id,
register_started_turn_message_id,
reset_turn_message_sequence,
)
def test_next_turn_message_id_uses_uuid4() -> None:
generated = uuid.UUID("12345678-1234-4234-9234-123456789abc")
with mock.patch("app.utils.turn_ids.uuid.uuid4", return_value=generated):
message_id = next_turn_message_id(
{
"callIdGed": "GED-555",
"protocol_id": "PRT-555",
"session_id": "session-555",
}
)
assert message_id == "12345678-1234-4234-9234-123456789abc"
def test_started_turn_message_id_can_be_registered_peeked_and_cleared() -> None:
source = {"session_id": "session-started-turn"}
reset_turn_message_sequence(source, clear_pending=True)
assert peek_started_turn_message_id(source) == ""
register_started_turn_message_id(source, "message-1")
assert peek_started_turn_message_id(source) == "message-1"
register_started_turn_message_id(source, "message-2")
assert peek_started_turn_message_id(source) == "message-2"
clear_started_turn_message_id(source, "message-1")
assert peek_started_turn_message_id(source) == "message-2"
clear_started_turn_message_id(source, "message-2")
assert peek_started_turn_message_id(source) == ""
def test_reset_turn_message_sequence_clears_started_turn_message_id() -> None:
source = {"session_id": "session-reset-started-turn"}
reset_turn_message_sequence(source, clear_pending=True)
register_started_turn_message_id(source, "message-to-reset")
reset_turn_message_sequence(source, clear_pending=True)
assert peek_started_turn_message_id(source) == ""