• 作成:

Servant/Warp/Waiのサーバ側エラーログの日本語をエスケープシーケンス無しで表示する

問題

servant: A family of combinators for defining webservices APIs でweb APIを作っているのですが、サーバ内部で例外が発生した時、標準エラー出力に出るエラーが読みにくいです。

具体的には、日本語を含む例外データが使われた時、エスケープシーケンスが使われます。

我々は日本語のNLPを行っているため、日本語を例外データから除外するというのは現実的ではありません。

haskell-jp/unicode-show: A Haskell Package for unescaping unicode characters in print and show. を使うとか、 pretty-simple: pretty printer for data types with a 'Show' instance. を使いたいです。

この記事が何ではないか

今回の目的はServantで例外が発生した時にサーバ側のログを良い感じにすることを目標としています。クライアント側に良い感じのエラーJSONを返すなどの話ではありません。クライアント側には、 servant-errors: Servant Errors wai-middlware とか使えばうまいこと行くかもしれません。まだ試してませんが。

調査

Servantのソースコードを見てもぱっと分かりませんでした。

とりあえず、 Tutorial — Servant documentation に従って、

stack new myproj servant

して色々見てみましょう。

そのままだと日本語も表示されます

とりあえず最小のエラーを引き起こすサーバを書いてどのように表示されるか調査しましょう。

module Lib
    ( startApp
    , app
    ) where

import Data.Aeson
import Data.Aeson.TH
import Network.Wai
import Network.Wai.Handler.Warp
import Servant

type API = "foobar" :> Get '[JSON] [String]

startApp :: IO ()
startApp = run 8080 app

app :: Application
app = serve api server

api :: Proxy API
api = Proxy

server :: Server API
server = error "ほげ"
2021-11-24T22:13:08 [✖  INT 130] ❯ stack run -- myproj-exe
ほげ
CallStack (from HasCallStack):
  error, called at src/Lib.hs:27:10 in myproj-0.1.0.0-8BSu9g2j0mlH3k0w9DylRX:Lib

日本語ちゃんと表示されました。ええ…

まあ理由は分かっていて、多分例外の内部の文字列に入れたりするとダメなんですよね。

例外型を用意しましょう。

{-# LANGUAGE DataKinds     #-}
{-# LANGUAGE TypeOperators #-}
module Lib
    ( startApp
    , app
    ) where

import           Control.Exception
import           Data.Aeson
import           Data.Aeson.TH
import           Network.Wai
import           Network.Wai.Handler.Warp
import           Servant

type API = "foobar" :> Get '[JSON] [String]

newtype AppExceptions
  = HogeError String
  deriving (Eq, Ord, Read, Show)

instance Exception AppExceptions

startApp :: IO ()
startApp = run 8080 app

app :: Application
app = serve api server

api :: Proxy API
api = Proxy

server :: Server API
server = throw $ HogeError "ほげ"
2021-11-24T22:18:27 [✖  INT 130] ❯ stack run -- myproj-exe
HogeError "\12411\12370"

はい、エスケープシーケンスが表示されました。

公式ドキュメントにヒントが書いてました

Sentryは今回は使いませんが、 Error logging with Sentry — Servant documentation を見た所、 setOnException関数とやらがあるらしいです。

これServantの領域じゃなくてWarpの領域みたいですね。

デフォルト設定はどうなってるか見てみましょう。 https://www.stackage.org/haddock/lts-18.18/warp-3.3.18/src/Network.Wai.Handler.Warp.Settings.html#defaultOnException

-- | Printing an exception to standard error
--   if `defaultShouldDisplayException` returns `True`.
--
-- Since: 3.1.0
defaultOnException :: Maybe Request -> SomeException -> IO ()
defaultOnException _ e =
    when (defaultShouldDisplayException e)
        $ TIO.hPutStrLn stderr $ T.pack $ show e

見ての通りshowを使われていますね。

これ、ushowとかではなく例外のより良い表示とかした方が良いのでは? displayExceptionとかさ… あっこれもエスケープシーケンス出すんだ。というか、

Default implementation: show.

Control.Exception

ってちゃんと書いてますね。

じゃあushow使うしか無いですね。

{-# LANGUAGE DataKinds     #-}
{-# LANGUAGE TypeOperators #-}
module Lib
    ( startApp
    , app
    ) where

import           Control.Exception
import           Control.Monad
import           Data.Aeson
import           Data.Aeson.TH
import           Network.Wai
import           Network.Wai.Handler.Warp
import           Servant
import           System.IO
import           Text.Show.Unicode

type API = "foobar" :> Get '[JSON] [String]

newtype AppExceptions
  = HogeError String
  deriving (Eq, Ord, Read, Show)

instance Exception AppExceptions

startApp :: IO ()
startApp =
  let settings =
        setPort 8080 $
        setOnException myOnException
        defaultSettings
  in runSettings settings app

app :: Application
app = serve api server

api :: Proxy API
api = Proxy

server :: Server API
server = throw $ HogeError "ほげ"

myOnException :: Maybe Request -> SomeException -> IO ()
myOnException _ e =
  when (defaultShouldDisplayException e) $
  hPutStrLn stderr $ ushow e

これでエスケープシーケンス無しで表示してくれるようになりました。

例外が文字列になってない構造的な情報を残しているならば、 pretty-simple: pretty printer for data types with a 'Show' instance. を使った方が良いかもしれません。

IOを返すので、 RIO の提供するロガーに差し替えるのも良いと思います。 unliftioをなるべくわかりやすく紹介してみます - Qiita 参考にすれば出来るでしょう。

出来ました。

runServer :: HasLogFunc env => App -> RIO env ()
runServer app = do
  settings <- createSettings
  logInfo $ display $ "Listening on port " <> tshow (getPort settings)
  liftIO $ runSettings settings $ application app

createSettings :: HasLogFunc env => RIO env Settings
createSettings = do
  runInIo <- askRunInIO
  return $
    setPort 3000 $
    setOnException (myOnException runInIo)
    defaultSettings

myOnException
  :: (MonadIO m, MonadReader env m, HasLogFunc env, Applicative f)
  => (m () -> f ()) -> Maybe Request -> SomeException -> f ()
myOnException runInIo _ e =
  when (defaultShouldDisplayException e) $
  runInIo $ logWarn $ display $ pShow e

普通にこのようにすればログをRIOのものに出来ます。ただそのままRIOのロガーで出力してもエスケープシーケンスが残るので、 pretty printを使ったのですが、まだエスケープシーケンスが残ります。調べてみると内部でshowを使っていたので、それは当然ではありますね。

デフォルトの設定でカラー表示になりますが、他のログ収集との互換性が問題になるならばRIOの設定やpretty-simpleの設定を弄ったほうが良さそうです。型内部の文字列にカラー文字列を入れるとカラーコードで崩壊するのでpShowNoColorを使った方が良さそうですね。

解決

これでひとまずサーバ側のエラーメッセージが後から見ても意味不明なことはなるべく避けられそうです。もちろんエスケープシーケンスぐらい後から修正出来ますが、サッとログは見れるに越したことは無いですね。