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.
ってちゃんと書いてますね。
じゃあ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
を使った方が良さそうですね。
解決
これでひとまずサーバ側のエラーメッセージが後から見ても意味不明なことはなるべく避けられそうです。もちろんエスケープシーケンスぐらい後から修正出来ますが、サッとログは見れるに越したことは無いですね。