Compare commits

..

5 Commits

Author SHA1 Message Date
lebaudantoine e0ab7f191f 📈(frontend) include LiveKit SIDs in the connection analytics event
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.

Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
2026-09-09 13:38:44 +02:00
lebaudantoine 455b315dbb 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.

Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
2026-09-09 12:09:13 +02:00
lebaudantoine 3089b03062 🔇(backend) silence noisy request summary info logs
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.

Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine 60febb3b57 🔇(backend) silence expected 401 warnings on /me
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.

These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine bf76ab1ddf 🔒️(backend) enforce display name setting on rename API
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.

Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.

Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
2026-09-08 01:26:27 +02:00
7 changed files with 89 additions and 28 deletions
+7
View File
@@ -10,8 +10,15 @@ and this project adheres to
### Fixed ### Fixed
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API - 🔒️(backend) enforce display name setting on rename API
### Changed
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
## [1.31.0] - 2026-09-08 ## [1.31.0] - 2026-09-08
### Added ### Added
+25
View File
@@ -0,0 +1,25 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
+8 -10
View File
@@ -52,12 +52,6 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400 status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError): class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete.""" """Webhook action fails to process or complete."""
@@ -74,6 +68,7 @@ class LiveKitWebhookEventType(Enum):
# Participant events # Participant events
PARTICIPANT_JOINED = "participant_joined" PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left" PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events # Track events
TRACK_PUBLISHED = "track_published" TRACK_PUBLISHED = "track_published"
@@ -153,10 +148,13 @@ class LiveKitEventsService:
try: try:
webhook_type = LiveKitWebhookEventType(data.event) webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e: except ValueError:
raise UnsupportedEventTypeError( logger.warning(
f"Unknown webhook type: {data.event}" "Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
) from e data.event,
room_name,
)
return
# Handle according to received webhook type # Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value) handler = self._webhook_handlers.get(webhook_type.value)
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config): def test_unknown_event_type(client, mock_livekit_config):
"""Should return 422 for unknown event type.""" """Should acknowledge (200) an unknown event type rather than reject it."""
event_data = json.dumps({"event": "unknown_event_type"}) event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload # Generate auth token for this specific payload
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token, HTTP_AUTHORIZATION=auth_token,
) )
assert response.status_code == 422 assert response.status_code == 200
assert response.json() == { assert response.json() == {"status": "success"}
"status": "error",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished") @mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,7 +16,6 @@ from core.services.livekit_events import (
AuthenticationError, AuthenticationError,
InvalidPayloadError, InvalidPayloadError,
LiveKitEventsService, LiveKitEventsService,
UnsupportedEventTypeError,
api, api,
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
@@ -665,22 +664,27 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive") @mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unsupported_event(mock_receive, service): def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
"""Should raise LiveKitWebhookError for unsupported events.""" """Unknown event types are logged and ignored, not rejected.
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
mock_request = mock.MagicMock() mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"} mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}" mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4()) mock_data.room.name = str(uuid.uuid4())
mock_data.event = "unsupported_event" mock_data.event = "some_future_event"
mock_receive.return_value = mock_data mock_receive.return_value = mock_data
with pytest.raises( with caplog.at_level("WARNING", logger="core.services.livekit_events"):
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event" service.receive(mock_request) # must not raise
):
service.receive(mock_request) assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
caplog.text
)
@mock.patch.object(api.WebhookReceiver, "receive") @mock.patch.object(api.WebhookReceiver, "receive")
+19
View File
@@ -1110,6 +1110,12 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging # Logging
# We want to make it easy to log to console but by default we log production # We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console. # to Sentry and don't want to log to console.
@@ -1122,10 +1128,16 @@ class Base(Configuration):
"style": "{", "style": "{",
}, },
}, },
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": { "handlers": {
"console": { "console": {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "simple", "formatter": "simple",
"filters": ["silence_expected_401"],
}, },
}, },
# Override root logger to send it to console # Override root logger to send it to console
@@ -1136,6 +1148,13 @@ class Base(Configuration):
), ),
}, },
"loggers": { "loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"core": { "core": {
"handlers": ["console"], "handlers": ["console"],
"level": values.Value( "level": values.Value(
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
useEffect(() => { useEffect(() => {
if (!isAnalyticsEnabled) return if (!isAnalyticsEnabled) return
const handleConnection = () => { const handleConnection = async () => {
// Preserve original connection timestamp across reconnections to measure // Preserve original connection timestamp across reconnections to measure
// total session duration from first connect to final disconnect. // total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now() connectionStartTimeRef.current = Date.now()
void captureMediaEvent('connection-event', {}) const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
} }
const handleReconnect = () => { const handleReconnect = () => {
captureEvent('reconnect-event') captureEvent('reconnect-event')
} }
const handleReconnected = () => { const handleReconnected = async () => {
captureEvent('reconnected-event') const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
} }
const handleSignalingConnect = () => { const handleSignalingConnect = () => {