소스 코드 읽기: erlang telemetry

4 minute read

telemetry는 erlang과 elixir에서 표준처럼 쓰는 메트릭(metric) 및 계측(instrumentation) 용도로 사용하는 동적 디스패치(dynamic dispatching) 라이브러리다.

주요 함수

:telemetry.attach/4 함수로 이벤트에 대한 핸들러를 등록한다. 등록한 핸들러는 :telemetry.execute/3 함수로 이벤트를 트리거할 때, 호출한다.

defmodule MyApp.Instrumenter do
  def setup do
    :telemetry.attach("world-monster-current", [:world, :monster, :current], &handle_event/4, nil)
  end

  def handle_event([:world, :monster, :current], measurements, metadata, _config) do
    IO.inspect(measurements, label: "measurements")
    IO.inspect(metadata, label: "metadata")
  end
end

이벤트 핸들러 모듈을 정의하고 MyApp.Instrumenter.setup/0 함수를 호출해 이벤트에 대한 핸들러를 등록한다.

iex> :telemetry.execute([:world, :monster, :current], %{zone_1: 5000, zone_2: 5000, zone_3: 5000}, %{spawn_algorithm: :sa_32112})
measurements: %{zone_1: 5000, zone_2: 5000, zone_3: 5000}
metadata: %{spawn_algorithm: :sa_32112}

:telemetry.execute/3 함수로 [:world, :monster, :current] 이벤트를 일으키면 핸들러로 등록한 MyApp.Instrumenter.handle_event/4 함수가 호출된다.

이벤트 이름을 리스트로 정의한다

[:web, :request, :start], [:web, :request, :success], [:web, :request, :failure] 이런 리스트로 이벤트 이름을 정의한다. 처음엔 낯설었지만 생각해보니 훌륭한 결정이다. 계층적으로 구성해야 수십 개에서 수백 개까지 되는 이벤트를 관리할 수 있다. 계층적으로 구성하지 않으면 그 이름을 어떻게 다 짓나. 계층적 이벤트 이름을 _ 문자로 구분해서 문자열로 구성하느니 그냥 리스트로 쓰는 게 편하다. 리스트를 지원 안 했으면 web_request_start 방식으로 이벤트 이름을 지었을 것이다.

이벤트 핸들러 동기 호출(synchronous call)

The handle_event callback of each handler is invoked synchronously on each telemetry:execute call. Therefore, it is extremely important to avoid blocking operations. If you need to perform any action that it is not immediate, consider offloading the work to a separate process (or a pool of processes) by sending a message.

간단하게 설계했다. :telemetry.execute 함수를 호출하면 핸들러를 찾아서 바로 호출하는 방식이다. 비동기로 처리하고 싶으면 메시지를 받아서 처리할 프로세스를 별도로 만들어야 한다.

이벤트 핸들러를 호출하는 코드

execute(EventName, Measurements, Metadata) when is_map(Measurements) and is_map(Metadata) ->
    Handlers = telemetry_handler_table:list_for_event(EventName), %-- 1
    ApplyFun =
        fun(#handler{id=HandlerId,
                     function=HandlerFunction,
                     config=Config}) ->
                try
                    HandlerFunction(EventName, Measurements, Metadata, Config) %-- 2
                catch
                    ?WITH_STACKTRACE(Class, Reason, Stacktrace)
                    detach(HandlerId), %-- 3
                    ?LOG_ERROR("Handler ~p has failed and has been detached. "
                               "Class=~p~nReason=~p~nStacktrace=~p~n",
                               [HandlerId, Class, Reason, Stacktrace])
                    end
        end,
    lists:foreach(ApplyFun, Handlers).

EventName 이벤트 핸들러들을 호출하는 코드다.

  1. EventName 에 대한 핸들러 리스트를 가져온다.
  2. execute 함수 인자로 이벤트 핸들러를 호출한다.
  3. 이벤트 핸들러 호출이 실패하면 핸들러를 제거한다. 핸들러 호출이 실패하면 핸들러를 제거하는데, 괜찮은 선택이다. 실패한 핸들러를 다시 호출한다고 성공할 이유가 없다. 해결할 수 없는데 지속해서 발생하는 에러를 그대로 놔둘 이유는 없다.

이벤트 핸들러를 등록하는 코드

핸들러를 ets(erlang term storage)에 저장한다. ets는 elixir에서 별도 설치 없이 사용할 수 있는 메모리 key-value 저장소다. telemetry_handler_table 모듈에서 이벤트 핸들러 관리를 담당한다.

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

gen_server:start_link/4 함수를 호출해서 erlang process를 만든다. 이건 좀 의외였다. 난 이런 것 없어 ets 접근 함수만 있을 걸로 예상했다.

insert(HandlerId, EventNames, Function, Config) ->
    gen_server:call(?MODULE, {insert, HandlerId, EventNames, Function, Config}). %-- 1

handle_call({insert, HandlerId, EventNames, Function, Config}, _From, State) ->
    case ets:match(?MODULE, #handler{id=HandlerId, %-- 2
                                     _='_'}) of
        [] ->
            Objects = [#handler{id=HandlerId,
                                event_name=EventName,
                                function=Function,
                                config=Config} || EventName <- EventNames],
            ets:insert(?MODULE, Objects), %-- 3
            {reply, ok, State};
        _ ->
            {reply, {error, already_exists}, State}
    end;

insert/4 함수를 통해 이벤트 핸들러를 등록한다.

  1. 동기 호출로 이벤트 핸들러를 등록한다. 성공 실패 여부를 리턴 값으로 줘야 하기 때문이다. 성공하면 ok, 실패하면 {error, already_exists} 을 리턴한다.
  2. HandlerId 로 이벤트 핸들러가 있는지 검사한다. key가 아니라서 ets:match 함수를 사용해서 검색한다. key가 아니라서 erlang process를 만들고 동기 호출로 이벤트 핸들러를 등록한다.
  3. 이벤트 핸들러를 등록한다. EventName 이 key가 된다.

이벤트 등록, 해지와 비교해 이벤트 발생 빈도가 현저히 높다. 이벤트 발생을 효과적으로 처리할 수 있는 설계로 가야 한다. EventName 을 key로 잡는 건 당연한 선택이다. :telemetry.execute/3 함수로 이벤트를 발생시키면 이벤트로 이벤트 핸들러를 가져와서 호출하기 때문이다.

HanderId 는 이벤트 핸들러 등록과 해지에 사용하는 식별자다. EventNameHandlerId 를 분리한 덕에 EventName 에 대한 이벤트 핸들러를 동적으로 추가하고 삭제할 수 있다. 범용적인 라이브러리 목표라면 좋은 선택이다. 나는 EventNameHandlerId 가 1:1 대응이라서 EventName 으로 HandlerId만드는 함수를 만들어서 쓰고 있다.

이벤트 핸들러를 저장하는 ets 테이블 생성

create_table() ->
    ets:new(?MODULE, [duplicate_bag, protected, named_table,
                      {keypos, 3}, {read_concurrency, true}]).

duplicate_bag 타입으로 만들어서 EventName 과 이벤트 핸들러가 1:N 관계를 맺을 수 있다.

protected The owner process can read and write to the table. Other processes can only read the table. This is the default setting for the access rights.

보호(protected) 접근 권한으로 테이블을 만들어서 이벤트 핸들러 등록은 telemetry 프로세스만 가능하게 했다.

{read_concurrency,boolean()} Performance tuning. Defaults to false, When set to true, the table is optimized for concurrent read operations. When this option is enabled on a runtime system with SMP support, read operations become much cheaper; especially on systems with multiple physical processors. However, switching between read and write operations becomes more expensive.

{write_concurrency,boolean()} Performance tuning. Defaults to false, in which case an operation that mutates (writes to) the table obtains exclusive access, blocking any concurrent access of the same table until finished. If set to true, the table is optimized to concurrent write access. Different objects of the same table can be mutated (and read) by concurrent processes. This is achieved to some degree at the expense of memory consumption and the performance of sequential access and concurrent reading.

이벤트 핸들러 찾기보다 추가 및 삭제 빈도는 현저히 낮다. 그래서 {read_concurrency, true} 옵션만 줬다. {write_concurrency, true} 옵션을 주면 이벤트 핸들러 추가, 삭제에 블로킹이 안 걸리지만 어디 공짜가 있나. 메모리 사용량이 많아지고 읽는 속도도 느려진다.

소감

erlang 코드 보는 건 너무 힘들다. 덩치가 작은 라이브러리라서 볼 수 있었다. attach 함수 이름은 괜찮은데, execute 함수 이름은 영 별로다. emit 정도가 낫지 않았으려나? 간단하지만 정갈하다는 느낌을 받았다. 대충한 결정이 하나도 없는 것처럼 느껴졌다.

참고