Telemetry
운영 중인 앱이 wireview 내부 비용을 자기 모니터링에 연결하기 위한 옵트인 계측 훅이다.
bench/가 재현 가능한 실험실 수치를 준다면, telemetry는 실제 트래픽에서의 수치를 준다.
개요#
이벤트 하나가 들어와 화면이 갱신되기까지 wireview는 네 단계를 지난다. 핸들러 실행, 템플릿 렌더, diff 계산, 그리고 (컴포넌트가 브로드캐스트한다면) 팬아웃이다. 이벤트당 비용의 60% 이상이 템플릿 렌더라는 것은 이미 측정돼 있지만 (docs/design/transport-abstraction.md), 그 비율은 템플릿과 데이터에 따라 달라진다. telemetry는 네 단계 각각의 소요 시간과 페이로드 크기를 Django 시그널로 내보내, 어떤 컴포넌트가 느린지 어떤 렌더가 큰지를 앱이 직접 관측하게 한다.
여기에 더해 운영자가 먼저 묻는 것 넷을 이벤트 시그널로 낸다(#124). 소켓이 몇 개 열려 있는가
(connection_opened·connection_closed), join이 왜 거절되는가(join_rejected), 채널 레이어가
메시지를 버리고 있는가(publish_failed). 전에는 로그 문자열로만 있었거나, 채널이 가득 찬 경우에는
핸들러를 죽이는 예외였다.
wireview는 아무것도 기록하지 않는다. 시그널을 보낼 뿐이고, 무엇을 어디에 쌓을지는 전적으로 앱의 몫이다.
켜기#
기본값은 꺼짐이다.
# settings.py
WIREVIEW = {
"TELEMETRY": True,
}
런타임에 바꾸려면 wireview.telemetry.enable() / disable()을 쓴다. 테스트가 이 방식을
쓴다.
from wireview import telemetry
telemetry.enable()
assert telemetry.is_enabled()
telemetry.disable()
시그널#
| 시그널 | 언제 | sender |
|---|---|---|
event_handled |
클라이언트 이벤트 핸들러가 끝났을 때 | 컴포넌트 클래스 |
component_rendered |
템플릿 렌더가 끝났을 때 | 컴포넌트 클래스 |
diff_computed |
렌더 결과에서 diff를 계산했을 때 | 컴포넌트 클래스 |
broadcast_published |
토픽으로 팬아웃 메시지를 발행했을 때 | 브로커 클래스 |
connection_opened |
수락된 소켓에서 세션이 시작됐을 때 | 세션 클래스(WireviewConsumer) |
connection_closed |
그 세션이 끝났을 때(소켓이 닫혔을 때) | 세션 클래스 |
join_rejected |
소켓이나 join이 거절됐을 때 | 세션 클래스 |
publish_failed |
채널 레이어가 발행이나 세션 전송을 거부했을 때 | 브로커 클래스(ChannelsBroker) |
구간 시그널#
위의 네 개(event_handled~broadcast_published)는 구간을 잰다. 공통으로 싣는 것:
| 키워드 | 뜻 |
|---|---|
duration_ms |
측정 구간의 소요 시간(밀리초, time.perf_counter 기준) |
payload_size |
바이트 수. 잴 수 없으면 None |
error |
측정 구간을 빠져나간 예외. 정상 종료면 None |
시그널별로 더 싣는 것:
| 시그널 | 추가 키워드 |
|---|---|
event_handled |
component_id, component_name, event (핸들러 이름). payload_size는 핸들러 인자 크기 |
component_rendered |
component_id, component_name, live (WebSocket 렌더면 True, HTTP 최초 렌더면 False). payload_size는 렌더된 HTML 크기 |
diff_computed |
component_id, component_name, changed (보낼 diff가 있으면 True). payload_size는 diff 페이로드 크기이고 changed가 False면 None |
broadcast_published |
topic. payload_size는 발행 메시지 크기 |
이벤트 시그널#
아래 네 개는 일어난 일을 알린다. duration_ms·payload_size·error 공통 키가 없고 자기 키만 싣는다.
| 시그널 | 키워드 |
|---|---|
connection_opened |
connection_id |
connection_closed |
connection_id, code(닫힘 코드, 모르면 None), duration_ms(세션이 산 시간, 세션이 열린 뒤에 telemetry를 켰으면 None), components(닫힐 때 살아 있던 컴포넌트 수) |
join_rejected |
reason(아래 표), component_name(origin이면 None), detail(로그와 같은 설명 문장) |
publish_failed |
kind("publish" 또는 "send_to_session"), target(토픽 또는 세션의 채널 이름), error(예외), dropped(아래) |
connection_opened는 Origin 검사를 통과하고 수락된 소켓에만 난다. 거절된 소켓은 join_rejected(reason="origin")
하나만 남기고 connection_closed도 내지 않으므로, 둘을 빼면 열린 소켓 수가 된다.
join_rejected의 reason은 닫힌 집합이다(telemetry.JOIN_REJECTED_REASONS). 지표 라벨로 써도 카디널리티가
늘지 않는다. 새 사유를 내는 코드를 넣으면 이 집합에도 넣어야 tests/test_telemetry.py가 통과한다.
reason |
뜻 | 클라이언트가 받는 것 |
|---|---|---|
origin |
소켓의 Origin이 ALLOWED_HOSTS에 없다. 수락 전에 거절(#96) |
핸드셰이크 403 |
expired |
서명 상태가 STATE_MAX_AGE보다 오래됐다 |
reload |
invalid |
서명이 맞지 않거나 다른 클래스용으로 서명됐다. 키가 어긋난 배포가 여기로 온다 | reload |
live_session |
페이지 경계가 거절했다(다른 경계, 로그아웃, 인가 술어) | reload |
halted |
on_mount 훅이 마운트를 멈췄다 |
요소 제거, 훅이 보낸 리다이렉트 |
error |
마운트가 예외를 던졌다. 로그에 트레이스백이 있다 | error |
expired는 오래 열어 둔 탭이면 평범하다. invalid가 늘면 서명 키가 프로세스마다 다르거나 배포 사이에 바뀐
것이다. 부모의 join에 딸려 온 자식 상태를 버리는 경우(서명 불일치·경계)는 거절이 아니다 — 자식은 부모 템플릿의
props로 다시 만들어지고 경고 로그만 남는다.
publish_failed의 dropped는 wireview가 그 메시지를 어떻게 했는지다.
True— 채널이 가득 찼다(ChannelFull). 받는 쪽이 따라오지 못한다는 뜻이고, 그 메시지 하나를 버리고 부른 쪽은 계속한다.wireview로거에 WARNING도 남는다. 레이어의group_send가 가득 찬 멤버에게 하는 것과 같다.False— 그 밖의 오류(브로커 연결 끊김 등). 시그널을 낸 뒤 예외를 다시 던진다. 발행한 핸들러는 전처럼 실패하고, 세션은 그 컴포넌트만 격리해 다시 join시킨다(errors).
보이는 것은 레이어가 던지는 것뿐이다. group_send는 가득 찬 멤버에 대해 아무것도 던지지 않는다 —
channels_redis는 버리고 INFO로 로그하고, in-memory 레이어는 말없이 버리고, channels-nats는 받는 쪽에서 WARNING
로그와 함께 버린다(pub/sub이라 보내는 쪽은 알 수 없다). ChannelFull을 던지는 것은 채널 하나로 보내는 send,
즉 send_to_session이다. 그러니 브로드캐스트 유실은 이 시그널이 아니라 레이어의 로그(channels_nats,
channels_redis.core 로거)에서 센다.
사용법#
수신자는 앱 준비 시점에 연결한다.
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self):
from . import telemetry_receivers # noqa: F401
# myapp/telemetry_receivers.py
import logging
from django.dispatch import receiver
from wireview import telemetry
log = logging.getLogger("myapp.telemetry")
SLOW_MS = 50
@receiver(telemetry.component_rendered)
def log_slow_renders(sender, component_name, duration_ms, payload_size, **kwargs):
if duration_ms > SLOW_MS:
log.warning("slow render %s %.1fms %sB", component_name, duration_ms, payload_size)
@receiver(telemetry.event_handled)
def count_events(sender, component_name, event, duration_ms, error, **kwargs):
statsd.timing(f"wireview.event.{component_name}.{event}", duration_ms)
if error is not None:
statsd.incr(f"wireview.event_error.{component_name}.{event}")
특정 컴포넌트만 보려면 sender로 거른다.
@receiver(telemetry.diff_computed, sender=Dashboard)
def watch_dashboard_payloads(sender, payload_size, changed, **kwargs):
if changed:
statsd.histogram("wireview.dashboard.diff_bytes", payload_size)
Prometheus로 내보낸다면 히스토그램 하나에 컴포넌트 이름을 라벨로 붙이는 편이 낫다. 운영 지표까지 연결한 전체 예시는 배포 문서의 모니터링에 있다.
RENDER = Histogram("wireview_render_seconds", "render duration", ["component"])
@receiver(telemetry.component_rendered)
def observe(sender, component_name, duration_ms, **kwargs):
RENDER.labels(component=component_name).observe(duration_ms / 1000)
작동 방식#
계측 지점:
| 단계 | 위치 |
|---|---|
| 이벤트 | ComponentRepository.dispatch_event — 핸들러 호출을 감싼다 |
| 렌더 | WireviewMeta.render_diff의 렌더 구간(라이브)과 WireviewMeta.render(HTTP·컴포넌트 태그) |
| diff | WireviewMeta.render_diff의 diff 구간 |
| 브로드캐스트 | WireviewMeta._send_broadcast와 wireview.utils의 send_to/asend_to — 어떤 Broker 구현이든 계측된다 |
| 연결 | WireviewSession.start·stop. 닫힘 코드는 컨슈머의 disconnect가 넘긴다 |
| 거절 | WireviewSession.command_join·_join_failed, Origin은 WireviewConsumer.websocket_connect |
| 레이어 거부 | ChannelsBroker.publish·send_to_session. 직접 만든 Broker는 스스로 내야 한다 |
각 지점은 telemetry.span(...) 컨텍스트 매니저를 쓴다. 켜져 있으면 Span이 시계를 읽고
빠져나갈 때 시그널을 한 번 보낸다. 꺼져 있으면 공유 no-op 스팬 하나를 돌려주므로
시계도 읽지 않고 페이로드 크기도 계산하지 않는다. 남는 비용은 with 문과 no-op 메서드
호출 몇 개뿐이다. 이벤트 시그널은 telemetry.emit을 거치고, 꺼져 있으면 플래그 확인 하나로 끝난다 —
연결 수명을 재는 시계도 켜져 있을 때만 읽는다. 로그는 telemetry와 무관하게 늘 남는다.
예외가 나도 시그널은 나간다. error에 예외가 담기고, 예외 자체는 그대로 전파된다.
오버헤드 실측#
make bench-compare BASE=50fe19d ARGS="--skip-ws" (macOS, Apple Silicon). 왼쪽이
계측 도입 전, 오른쪽이 도입 후(telemetry 꺼짐, 기본값).
metric 50fe19d 50fe19d change
payload_bytes.list.first_render 2,260 2,261 +0%
payload_bytes.list.change_one_item 602 602 +0%
timing.list.event_ms 0.536 0.535 -0%
timing.list.template_render_ms 0.370 0.367 -1%
timing.flat.event_ms 0.151 0.149 -1%
memory.list.component_kb 25.674 25.674 -0%
꺼져 있을 때의 차이는 회차 간 잡음(±1%) 안이다. 페이로드의 ±1~3 바이트도 서명 상태의 압축 길이가 회차마다 흔들리는 것이지 계측 때문이 아니다.
켰을 때의 비용은 이벤트당 약 0.007ms 고정이다. 같은 하드웨어에서 라운드 7회 중앙값:
metric off on change
list.event_ms 0.312 0.319 +2.5%
list.template_render_ms 0.216 0.214 -0.9%
flat.event_ms 0.153 0.160 +4.8%
flat은 스칼라 7개짜리 가장 싼 컴포넌트라 고정 비용이 비율로 크게 보인다. 렌더가
무거워질수록 비율은 작아진다. 이 수치는 수신자가 아무것도 하지 않을 때의 것이고,
실제 비용은 연결한 수신자가 무엇을 하느냐가 지배한다.
주의사항#
- 수신자는 렌더·이벤트 경로 안에서 동기로 실행된다. Django 시그널이 그렇다. 수신자에서 네트워크 I/O를 하면 그 시간이 그대로 사용자 지연이 된다. 카운터를 올리거나 큐에 넣는 정도로 끝내고, 전송은 별도 워커에 맡긴다.
payload_size는 계측 시점의 크기다. 실제 WebSocket 프레임은 명령 봉투({"command": "render", "payload": ...})가 더해지므로 조금 더 크다. 압축이 걸려 있으면 더 작다.component_rendered는 컴포넌트마다 난다. 중첩LiveComponent가 많은 페이지의 최초 HTTP 렌더에서는 컴포넌트 수만큼 시그널이 난다.diff_computed의changed=False는 정상이다. 상태가 안 바뀌면 보낼 diff가 없다. 이 비율이 높다면 불필요한 이벤트나skip_render로 줄일 여지가 있다는 신호다.- 커스텀
Broker도 계측된다. 계측이Broker.publish호출부에 있기 때문이다. 반대로 브로커를 거치지 않고 채널 레이어를 직접 만지는 코드는 잡히지 않는다 — 그런 코드는 애초에tests/test_transport.py의 가드가 막는다.
관련 기능#
- HTML Diff —
diff_computed가 재는 대상 - temporary_assigns — 렌더 메모리 줄이기
- bench/README.md — 실험실 수치
- docs/PERFORMANCE.md — 성능 개요